Skip to content
Adam Probert

Field Notes

Free tier as a design constraint

How many free tier services does it take to build an app?

Dwg no.
FTD-01
Date
6 Oct 2026
Read
6 min
Tags
dilectus, vercel, neon
Receipt

Dilectus is a Warhammer 40,000 army list builder I've been building in my spare time. It has accounts, lists that sync across devices, a rules engine that checks every list, a daily data import from a number of sources and live battles where two devices used at the same table stay in step with each other.

It costs me £1 a month, which is the domain name. The other bill is a £20 a month Claude subscription. Everything else runs on free tiers, and this post is about how I took free tiers to their limits.

Dilectus List Builder

01The stack

ServiceWhat it doesThe Free Tier
Vercel (Hobby)Next.js site and APIFunctions up to 2 GB memory and 5 minutes
Neon (Free)Postgres1 GB storage per project, 100 CU-hours a month
GitHub ActionsCI and the daily data import2,000 minutes a month on a private repo
ResendVerification and password reset emails3,000 emails a month or 100 a day
Cloudflare TURNRelaying WebRTC when phones can't connect directlyFirst 1,000 GB free
Public STUN (Cloudflare and Google)WebRTC connection setupFree

Each one only stays free if you design around its limits, so most of the interesting decisions came from those limits and how to use/abuse them.

02The import is too big for Vercel runners

The game data comes from BSData (units, points, rules) and Wahapedia (stratagems and core rules). A full import downloads both, builds a normalised schema, runs through a series of verifications, then loads it into Postgres (Neon). Every army in both editions goes through it.

That build needs about 2 GB of memory. A Vercel Hobby function gets 2 GB and 5 minutes, so I was seeing timeouts and memory limits hit regularly.

We were already using GitHub for version control, they have pipelines that can be scheduled.. Digging in, a GitHub-hosted Linux runner on a private repo has 2 CPUs and 8 GB of RAM. It'll happily run for 20 minutes too. So the import became a scheduled GitHub actions workflow:

yaml
on:
  schedule:
    - cron: "33 5 * * *"
  workflow_dispatch:
    inputs:
      force:
        description: Rebuild even if no source changed
        type: boolean
        default: false
...

Every morning it checks the latest version of each source (a BSData commit, Wahapedia's Last_update timestamp and content hashes of our own rule overlays and pipeline config) against the active import. If nothing moved it stops after a few small requests, which keeps the minutes down.

If something has moved, it rebuilds. In the event the new build fails verification, we leave the previous version alone and flag it in a GitHub issue. That's the monitoring sorted without paying for anything extra.

03Fitting all this into a Neon Instance

Neon's free plan gives you 1 GB of storage per project. A full dataset fits easily enough. However keeping old versions of it doesn't.

The original plan was to keep every import, so a list built against last month's points could still open against last month's data. Once every army in both editions went in, each import got a lot bigger and that plan stopped making sense. So we keep only the active import for each game system. Older data can always be rebuilt from the sources' version control (every import records the exact BSData commit and Wahapedia timestamp it came from). Lists just get re-checked against the current data when they open.

The other Neon limit is compute hours. The database scales to zero after 5 minutes idle, which is great until something polls it every few seconds and it never gets to sleep. Which brings me to the live battles.

04Why WebRTC

Battle mode tracks a game as it's played (rounds, command points, damage). Players often have it open on a phone and a tablet at once. Later on, two players wanted to share one battle. The first version polled the account every 5 seconds. That's 720 requests an hour per open battle, every change took up to 5 seconds to appear and the database never slept.

The normal answer is WebSockets. On Vercel those are in public beta, but a connection is pinned to one function instance with nothing to reach connections on other instances. Two phones in the same game can land on different instances, so you'd need a pub/sub service in the middle too (Upstash Redis or similar). On Hobby a connection closes after 5 minutes anyway. Hosted realtime services like Ably, Pusher and Supabase Realtime all have decent free tiers. I just didn't want another vendor, another account and another dashboard to keep an eye on.

Web RTC Flow

Then you look at what's actually happening. The devices are usually at the same table, often on the same Wi-Fi. A battle update is only a few kilobytes. They don't need a server in the middle at all, they need introducing.

So the devices connect to each other with WebRTC data channels. The API only does the introductions:

  • A device posts a hello to a signals table in Postgres when it joins.

  • Of any two devices that meet, the one with the lower id sends an offer and the other an answer.

  • ICE candidates are gathered up front (for up to 3 seconds) rather than trickled, so one offer and one answer opens the connection. That keeps the signalling down to a handful of rows.

  • Old signals are deleted as new ones arrive, so there's no cleanup job.

WebRTC Connections

After that, every change goes straight down the data channel and shows on the other device in milliseconds. Account sync is still the record of truth, the channel just gets there first.

Around 10 to 25% of device pairs can't connect directly, mostly phones on mobile data. For those the ICE endpoint mints short-lived Cloudflare TURN credentials and the traffic relays through Cloudflare. At a few kilobytes a message the 1,000 GB free allowance isn't something I'll get near.

Going live only when it's needed

The next version only joins a room once another of the account's devices actually has the list open. A small presence table records which device has which list open. The answer rides back on requests the app was already making (a header on each save says how many other devices are there). Most people use one device at a time, so for them an open list now makes no background requests at all.

The numbers ended up like this:

Requests per open battle
Polling every 5 seconds12 a minute
WebRTC, connectedabout 2 a minute
One device on its ownnone

It wasn't free in effort though. Browsers drop connections when a phone sleeps. Reloading a page left "ghost" devices that the others kept trying to reach. Fixing those took a bye message, sent over the channel and to the API as a keepalive request, plus device ids that survive a reload. There's a hidden connections page that shows how each link is travelling (same network, direct or relayed) and the round trip time. I'd build that first next time.

05GitHub Actions minutes

This is the one that's getting tight. 2,000 minutes a month sounds like a lot until you have unit tests, Playwright, a performance suite and a daily import all drawing from it.

What we've done so far:

  • Unit tests, typecheck and lint run on every PR. That's the cheap stuff.

  • Playwright only runs while a PR has an e2e label, for PRs that change a lot of UI. Otherwise it gets run locally.

  • next build doesn't run in Actions at all, Vercel builds every PR as a preview deploy anyway.

Performance Issue in github
  • Performance checks used to run on every PR. Now they run nightly, and only if main changed that day. A regression opens an issue the same way a failed import does.

  • The import stops early when no source has changed, which is most days.

It'll need more of this. Making the repo public would make standard runners free and unlimited and bump them to 16 GB, which is tempting.

06Was it worth it?

For a side project, yes. The limits pushed the design somewhere better than where it would have ended up with money to spend. The database sleeps when nobody's using it, the import can't take the site down and the live battles are faster than they would have been through a server.

If you're starting something and wondering whether you need to pay for hosting from day one, you probably don't. Read the limits pages before you pick the architecture though, because the limits end up shaping it.

More field notes
Contact

Say hello.

If you’re stuck on something or thinking about starting a project, I’m happy to chat it through.

Get in touch
  • Something you're building, or thinking about building
  • A problem you're stuck on and want another pair of eyes on
  • Anything on this site you want to know more about
  • A role or piece of work worth a conversation