Standard Reader

me, my atproto backend in a bottle and i

flo-bit
Apr 18, 2026 · 6 min read

really love building things for atproto and one of the things i like most about it, is that I can basically ignore a lot of the stuff i don't want to have to deal with, like user management and storage and all that backend jazz and can just concentrate on building my nice interfaces and making fun frontend stuff (at least after i figured out atproto oauth shudders).

so i've been building stuff without any backend and that mostly worked (blento didn't have any kind of backend except for some cache up till ~800 users), but sadly some things are just not possible/easy to do if you don't have some kind of aggregating backend for records, so even though i've been loudly evangelizing the you don't need a backend approach, at some point i had to bite the bullet and build something backend-ish.

introducing contrail

contrail is what i call my backend-in-a-bottle, a little thing that i've been using whenever i kinda need a backend for some project but can't really be bothered, i wanted these things:

  • easy to use (just define a config and you're done)
  • typed endpoints that allow you to filter, sort, paginate and hydrate things
  • runs on serverless platforms like cloudflare workers so I don't have to pay for running something all the time (though this has some drawbacks as we'll see)

here's how it works in practice using events and rsvps (with the community lexicons) as an example:

  1. you define a config with the collections you want to index and what kind of filters, hydration, etc you need.
export const config: ContrailConfig = {
  namespace: "com.example",

  collections: {
    event: {
      collection: "community.lexicon.calendar.event",
// sort or filter by these properties
      queryable: { 
        mode: {},
        name: {},
        status: {},
        startsAt: { type: "range" },
        endsAt: { type: "range" }
      },
// hydrate/count collections that link to this record
      relations: { 
        rsvps: {
          collection: "rsvp",
          groupBy: "status",
          count: true,
          countDistinct: "did",
          groups: {
            interested: "community.lexicon.calendar.rsvp#interested",
            going: "community.lexicon.calendar.rsvp#going",
            notgoing: "community.lexicon.calendar.rsvp#notgoing",
          },
        },
      },
    },
    rsvp: {
      collection: "community.lexicon.calendar.rsvp",
      queryable: {
        status: {},
        "subject.uri": {},
      },
// hydrate records this lexicon references
      references: { 
        event: {
          collection: "event",
          field: "subject.uri",
        },
      },
    },
  }
};

the coolest bit imo is in that relations block: without writing any query code i get a rsvpsCount and rsvpsGoingCount/rsvpsInterestedCount/rsvpsNotgoingCount on every event record, so i can filter events by "at least 5 people going", sort by rsvp count, etc. on the rsvp side, references means I can hydrate the full event record into each rsvp in a single call. that's most of the backend work for any social-ish app we need.

  1. a script downloads all lexicons you need for that (or you define your own) and creates a few new ones for the endpoints (so everything is typed using xrpc)
  2. another script backfills those collections and puts them in a DB for you (only takes a few minutes for events currently, dont try it with bluesky records though :P)
  3. it ingests new records using a filtered jetstream either continously or every minute or so (for serverless platforms)
  4. you get automatic (typed) XRPC endpoints, with filter, pagination and hydration, so e.g. for my events usecase it made 4 endpoints:
  • /xrpc/com.example.event.getRecord
  • /xrpc/com.example.event.listRecords
  • /xrpc/com.example.rsvp.getRecord
  • /xrpc/com.example.rsvp.listRecords

those endpoints accept a bunch of parameters for filtering, pagination and hydration, e.g. to get all events after a certain date by one person where at least 5 people have rsvp'd as going you'd query:

/xrpc/com.example.event.listRecords
  ?actor=flo-bit.dev
  &startsAtMin=2026-02-01
  &rsvpsGoingCountMin=5
  &sort=startsAt

and get back something like:

{
  "records": [
    {
      "uri": "at://did:plc:abc123/community.lexicon.calendar.event/abac",
      "did": "did:plc:abc123",
      "name": "my cool event",
      "startsAt": "2026-03-15T18:00:00Z",
      "mode": "inperson",
      "rsvpsCount": 12,
      "rsvpsGoingCount": 8,
      "rsvpsInterestedCount": 3
    },
    ...
  ]
}

the counts are pre-aggregated (counted at ingest, not at query time) so this query is cheap regardless of how many rsvps exist. add ?hydrate=rsvps and you get the actual rsvp records grouped by status embedded in each event. add ?profiles=true and you get an additional profiles array with every referenced user's profile record resolved, all in one roundtrip.

costs

so ive been running 4 of those for a bit on cloudflare workers and d1 though only 2 really have users currently and i'm confortably still in the 5 usd/month plan, some more detailed stats:

blento

  • total users: ~850
  • visitors: 23k last 30 days
  • 900M read queries per month
  • 600k write queries per month

atmo.rsvp

  • total users: ~900
  • visitors: 20k last 30 days
  • 90M read queries per month
  • 30k write queries per month

where users are people with at least one of my specified collections and visitors are unique visitors according to cloudflares dashboard (note that most of those visitors are probably not real visitors but a shitload of bots instead but sadly i still do database queries for those :P)

cloudflare d1 pricing includes 25B read queries and 50M write queries per month in the 5 dollar plan, so extrapolating that blento could go up to 20k users and 400k visitors/month and I'd probably still be in the 5 usd plan (at least for the db, other things probably not, i'll calculate that some other time). pretty sure that at some point cloudflare stops being worth it cost-wise, but at least for a low-ish user count (and multiple separate projects) i dont think it gets that much cheaper.

drawbacks

so this is all pretty nice and easy and affordable, but of course has some drawbacks:

  • we're using jetstream so can't really check using that cool crypto shit (TM) if a record was actually made by a person, we just gotta trust the person running the jetstream.
  • for serverless where we ingest new events only every minute, that means we're up to a minute out-of-date, that's mostly fine imo, but for some things really not that cool for example if I'm creating an event i don't wanna have to wait up to a minute for it to actually appear, my solution is a small notify endpoint that we can call that immediately checks for a created/deleted records.
  • everything has to fit in one database. no sharding, no partitioning. this sounds like a future-me problem though
  • backfilling is its own little subsystem of pain. users have a shit ton of records across lots of PDSes, and some PDSes are slow or down or rate-limiting, so there's a whole retry/cursor/failure-tracking layer just so one broken PDS doesn't fuck up the whole ingest (i spent a not-small amount of time debugging why records were occasionally going missing)

blessed be the monoliths

(obviously this is not meant to be taken literally, it refers to any manufacturers of fullstack repos)

as contrail is just a bit of typescript and a cron trigger (for cloudflare workers), i can pretty easily add it straight into my existing (frontend) sveltekit repos for blento and atmo.rsvp. blessed be the monolithic "fullstack" codebase (that are not a monorepo because those are a pain)

disclaimer

in case you haven't guessed by now, i'm very much not a backend person and all this is very much backend stuff. you get one guess regarding who wrote most of this code (if you guessed ai you'd be right and honestly the parts i wrote myself are probably the worst parts) and i'm too shit at this to actually review the code properly, so i don't necessarily recommend other people use this just yet. That being said i've been using this for a bit now and it mostly seems to work (any code reviews by people who actually know what they're doing would be much apresh though :P).

sauces

blento

https://blento.app/ https://github.com/flo-bit/blento

atmo.rsvp

https://atmo.rsvp/ https://github.com/flo-bit/atmo-events

contrail

https://github.com/flo-bit/contrail

Did this enjoy this document?

Give it a heart — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions