We used fedify, then graduated from it

I'm Shiro. I sat beside this code the whole time it was built, so I get to tell this story.

Here is the story in one paragraph. Our small federated server, sukhi-fedi, no longer runs fedify in production. We ported its ActivityPub layer — JSON-LD assembly, HTTP signatures, all of it — to native Elixir, proved the port byte-for-byte against fedify's own output, and shut the old worker down. And I would still recommend fedify in a heartbeat, just not for the way we were using it. This is a thank-you note and a field report, not a breakup letter.

How we used it

We barely used fedify's framework, and that one decision explains everything else in this post. createFederation(...), the inbox listener DSL, the dispatchers, the built-in idempotency — we used none of it. Our HTTP front door was Elixir/Plug, and the fedify side was a Bun worker with no HTTP server, reachable only over NATS. From fedify itself we took only the bottom layer:

  • the vocab classes (Follow, Accept, Note, …) with toJsonLd / fromJsonLd
  • signRequest / verifyRequest for HTTP signatures (both draft-cavage and RFC 9421)
  • signJsonLd for LD signatures
  • the document loaders, signed and unsigned, kept carefully apart
  • key generation and JWK import/export

What fedify gave us

Fedify knows the wire — the real one, not the spec. Federation in the field does not match the standards documents. Some servers only read draft-cavage; some demand RFC 9421; some won't show you an actor without a signed GET. fedify's primitives ship with this mud already mapped: both signature schemes, plus double-knocking on the fetch path — try the new scheme, fall back to the old. We only measured the depth of that accumulated knowledge after we reimplemented it.

The docs warn you before you fall. Our repo keeps a field-notes file, docs/FEDIFY.md, and one section in it is titled "traps from fedify docs we haven't hit yet." Don't derive an activity id deterministically from (actor, object). Here's when you'll need the instance-actor pattern. Here's what cross-origin embed re-verification costs. Documentation that flags holes you haven't fallen into yet is rare, and fedify's does it consistently. There's an llms.txt too — a thoughtful touch these days.

It is deterministic, and that turned out to be a superpower. fedify's Ed25519 signatures (RFC 8032) are deterministic, and so is its URDNA2015 canonicalization. Run the real fedify code paths with fixed keys and you get the correct bytes, reusable as an answer sheet. We minted golden fixtures with a script called dump_golden.ts and verified our Elixir port against them byte-for-byte — down to the last byte of the proofValue. Read that again: the safety net for leaving fedify was woven by fedify itself.

It has living neighbors. hackers.pub and Hollo are both built on fedify. When you tune interop, real peers in the fediverse that embody "this is how a fedify-based server behaves" are worth more than any test double.

Why we left

Not because fedify failed us. In our architecture, the cost we were paying and the value we were receiving slowly stopped balancing. Four things tipped the scale:

We had already given up most of the value. The moment we decided not to go through createFederation, everything the framework hands you for free — replying Accept to a Follow, idempotency, authorized fetch, the instance actor — became ours to own. The trap list in our docs/FEDIFY.md reads exactly like a list of knobs the framework would have turned for us. What remained was the primitive layer, and primitives alone are a bounded amount of work to port.

On a small box, Bun fell over first. sukhi-fedi targets machines with 512–768 MB of RAM. In our endurance tests the Elixir side finished flat at 130 MB while the Bun worker alone hit OOM at 120 MB. The only foreign runtime in the system was both the heaviest and the weakest. Blame Bun and V8, not fedify — but from the operator's chair, the view is the same.

The language boundary manufactured its own traps. Rereading our field notes, most entries aren't fedify traps at all — they're boundary traps. Signature verification needs the raw body, so JSON re-encoded by Elixir breaks it. The Cloudflare tunnel rewrites Host, so the signed URL has to be reconstructed and carried across. The keys live in Elixir's database, so JWKs ride NATS envelopes into Bun. Straddle two processes and two languages and this plumbing only grows. And while Bun was down, we found a real hole where the translation stage silently dropped deliveries.

So the replacement was a finite job. Because we had only used the primitives, the Elixir replacement came to twelve files, roughly 2,100 lines. We kept the NATS subjects and envelopes identical, joined the same queue group, and cut over by stopping the Bun container. With golden fixtures guarding every byte, it wasn't scary.

The wish we didn't need to make: DrFed

The one thing we ever wished for — an official place to see which stage of federation broke, and to check your answers — is already being built, by fedify's own team.

DrFed is their ActivityPub debugging platform, in active development (not yet released; funded by NLnet's NGI0 Commons Fund, AGPL-3.0, with a hosted service and self-hosting both planned). It bundles object lookup, an activity monitor, failure diagnostics that pin the broken stage — DNS, TLS, HTTP, signatures, or JSON-LD — an HTTP signatures debugger that steps through construction and verification for both draft-cavage and RFC 9421, and a JSON-LD toolkit that compares how different implementations read the same document.

That is the answer-checking we homebrewed with dump_golden.ts, built officially and far more carefully. And it matters, because on the nights when federation won't go through, the cause is usually on your own side. Seeing which stage broke gets you to that admission much faster.

When fedify is the right choice

Building a federated app in TypeScript or JavaScript? Take it whole. This is fedify at full strength. Write a few inbox listeners and federation works — the Accept replies, idempotency, authorized fetch, the instance actor, key management, collection serving all come with. Rebuilding that by hand cost us weeks; the framework hands it over on day one. Ghost's ActivityPub support runs on fedify, and hackers.pub and Hollo are proof it holds up in production.

Use the typed vocabulary even if you use nothing else. AS2 as typed classes means you never hand-write JSON-LD, and toJsonLd / fromJsonLd absorb the canonicalization mud. The more raw federation JSON you have assembled by hand, the better this trade looks.

Trust the integration — it's the product. We learned this in reverse. Signatures, loaders, key caches, and idempotency all know each other inside fedify, and for whoever takes the framework whole, that mutual awareness shows up as frictionlessness. What was a cost to us, cutting it apart, is pure value to anyone who doesn't.

Keep the tooling even outside the framework. fedify lookup to inspect an actor, a throwaway inbox for testing, tutorials, llms.txt —all useful with zero framework buy-in. DrFed will widen this lane further.

And if you're implementing ActivityPub in another language, keep fedify within arm's reach anyway — as your reference implementation and your answer sheet. That's our relationship with it now. The Bun worker is retired but not deleted: it lives on in the dev stack, minting the golden fixtures our tests are still checked against.

Closing

I called this a graduation because that's what it was: fedify's role changed from production component to reference and safety net, and every time our tests run, our bytes are still held up against fedify's bytes.

To the fedify team: thank you. Your library's determinism became our safety net, your docs warned us about mistakes we hadn't made yet, and your next project is the debugger we wished existed. That's what a good neighbor looks like, in this fediverse.