Ten months on PostChart
I was hired off Upwork to build a custom Facebook scheduling SaaS solo, end to end. Ten months, 100+ users, one stubborn platform API, and a scheduler I built instead of bought.
- read
- 8 min
PostChart is a custom Facebook page management SaaS. The closest comparisons are Buffer or Meta Business Suite, but it's built for a specific corner of the content world neither of them serves cleanly: reaction-driven viral and puzzle content at high volume. Hundreds of videos per page per month. Months scheduled in advance. Multiple operators on the same page — the client called them "pod pages." Content that's evergreen enough to recycle on a predictable rotation.
I was hired off Upwork by Eric Bihr to build it, solo and end-to-end. Ten months in total. By the time I handed it off it was a live SaaS with 100+ users, ~500 pages under management, and around 10,000 scheduled posts a month. This is what that ten months actually looked like.
Why custom
The framing question for any tool like this is "why not Buffer?" or "why not Meta's own scheduler?" Both are mature, both ship today.
The answer was three features none of them had together:
Bulk scheduling from a Drive folder. Buffer has Drive integration but no bulk; Meta Business Suite has bulk but only via spreadsheet, no Drive. Eric's team's workflow was upload-to-Drive-first, schedule-second. Either competitor was a workflow gap.
Predictable recycling. Content was evergreen. After a batch ran through, they wanted it to run again in a specific pattern they could reason about. Neither tool offered that.
Per-user attribution and collision prevention. Pod pages have multiple operators on the same page. They needed to see who scheduled what, and the scheduler had to refuse to publish two posts at the same minute on the same page.
Add a real RBAC system, a sane subscription flow, and room to add bespoke features later, and the build-vs-buy math leaned hard toward build.
The Upwork posting didn't have all of that scoped. Eric had a working theory of what he wanted but no spec. The first few weeks were mostly me asking the right questions and converting answers into a list of features I could actually build.
My role
End-to-end. Stack selection, architecture, every UI screen, every backend route, the deploy, the Meta verification dance, the Stripe integration, the data model, the scheduler core, and the eventual handoff. There was no second engineer until month nine, and at that point I was training them, not collaborating with them.
Stack: React + Vite + TypeScript + Tailwind + shadcn on the front. Node + Express + TypeScript on the back. MongoDB Atlas as the source of truth, Redis on EC2 for the scheduler, S3 for media, a single EC2 box for the API. Eric already had EC2 capacity running other products of his, and the existing infra was part of why we picked this shape — adding a managed queue or a serverless cron didn't fit his cost model.
The standard stuff, fast
Auth was the first thing. I built it from scratch — no Auth0, no Clerk, no NextAuth. Magic-link email plus Google OAuth, JWT in an HTTP-only cookie, the simplest version that could possibly work. Nothing about this product's auth requirements justified pulling in a framework. Building it directly took two days; integrating any framework would have taken longer once you account for understanding its conventions.
Then the UI — Eric had pointed me at Meta Business Suite's bulk upload screen as a starting point. I built that, refactored it to match a calmer visual language, and laid the Content / History / Settings / Profile tabs out around it. The RBAC system underneath the page assignments came next. None of this part is the interesting story. It was a couple of weeks of head-down implementation to get to where the actual hard part could start.
The scheduler, which is the engineering anchor
The bulk scheduler is the spine of the product. Months of posts, multiple pages, multiple operators, all needing to fire at specific minute boundaries with no collisions. This was the decision I spent the most time on.
The off-the-shelf answer was BullMQ. Mature, well-trodden, would have shipped in a week. I rejected it for two reasons. First, Eric was paying per GB of RAM on shared infra. A BullMQ job carries roughly an order of magnitude more weight than what I actually needed — at our scale ceiling (~1M scheduled posts in flight) the difference was meaningful, not theoretical. Second, BullMQ's job model is built around opaque work units. Mine wasn't. Every "job" was already a row in MongoDB with a publish time and a post body; the queue's only responsibility was knowing when to fire and giving me a stable lock on the firing.
So I built it directly. Redis sorted sets, scored by epoch milliseconds, one set per page. A poller every few seconds pops anything past its score and hands it to the publisher. MongoDB stays the source of truth — the sorted set is just a fast time index. On startup the index is rebuilt from Mongo; on every scheduling write, both stores get updated. A 1M-post ceiling fits in 50–80MB of Redis. Locks are a small custom primitive, not a Redlock-style library, because the surface area is two operations and the failure modes are simple.
The reasonable critique here is "you reinvented a queue." I did. The queue I needed was small enough that the boilerplate of wiring BullMQ's dashboards, job-retention policies, and worker lifecycle into our stack was about the same effort as writing the primitive myself. The custom version has nothing I don't need.
The Facebook Graph API, which was the actual hard part
Every story about integrating with a platform API ends up at the same place. The docs are out of date. The forums are dead. The rate limits are undocumented and shift without notice. The only official support channel is for app review, and that's never the help you actually need.
The most expensive single feature I built and then removed was bulk Story uploads. Eric's team wanted to drop ten or twenty Stories at a time across a page on the same rhythm as their feed posts. The Graph API exposes the endpoint. I built it.
It worked in testing. It did not work in production. Stories, it turned out, count against an app-level rate limit, not a per-page one — which means one page's burst of Story uploads burns down quota for every page in the app. With 500 pages active, this is a non-starter. One heavy user freezes everyone.
I spent a week on this. Forum threads from 2021 hinting at the same behavior with no resolution. Manually replayed the same upload pattern through Buffer and watched it succeed at volumes that broke us. Couldn't reproduce whatever they were doing. There's almost certainly a private pipeline negotiated directly with Meta — small dev shops don't have that lever.
So I had to go back to Eric and tell him the feature couldn't ship. The version of that conversation that lands well is not "Meta's rate limit is broken and Buffer must have special access." It's "the underlying mechanism doesn't work the way the docs describe, here's what we tested, here's the ceiling, here's what we can do instead." We ripped it out of the product and shipped without it. Months later we revived a narrower version through browser automation for a small group of power users — Eric himself was the heaviest — with the explicit caveat that this path would never scale to the full user base.

That conversation, explaining a hard limit without sounding like you're making excuses, is in retrospect the most useful skill the project taught me.
Stripe and the Meta dance
Subscription billing was Stripe. Eric brought rough plan-tier ideas, I came back with what was implementable and what would cause edge-case pain, we settled, I built it. Stripe Checkout, webhooks, and a subscription state model that kept Mongo in sync with whatever Stripe believed.
The pre-launch chore was Meta app verification. The app couldn't be useful to the public until Meta reviewed it for the Pages permissions. I recorded the walkthrough videos, wrote the use-case justifications, submitted. Rejected the first time for reasons that didn't survive a careful re-reading of the response. Resubmitted with a tighter narrative and a screen recording that explicitly addressed the rejected concern. Approved.
I also walked Eric through generating his own GCP credentials, creating the Facebook developer account under his name, setting up Stripe live keys, transferring app ownership. None of this is engineering work, exactly. It's the part of "delivering a SaaS" that nobody tells you about until you've shipped one.
The long tail
The original Upwork brief read like a two-month project. It became ten.
Most of that was feature pull. Once core was live, every conversation produced another small ask — analytics views, a new content type, a tweak to recycling logic, a permissions split. Individually none of them were big. Cumulatively they were the whole second half of the project.
The honest version of how that felt: somewhere around month seven I started getting bored. Not unhappy — Eric was, genuinely, one of the best clients I could have hoped for on a long contract. He listened to recommendations, understood when I pushed back, was direct about constraints. But ten months on one codebase as a junior freelancer is also ten months your Upwork profile shows one project. Cash was up. Project count was flat. That's a real tradeoff and I'd weigh it differently next time.
The handoff
Nobody scheduled the handoff conversation. It just emerged.
I had proposed a rate increase a couple of months earlier and we'd implemented it. Eric tried to bring in outside investment to fund a bigger build and the round didn't close. To keep monthly burn manageable he wanted to bring on a junior developer and have me transition out. I agreed.
The transition was roughly a month. I reviewed the junior's PRs, walked them through the scheduler internals (which they would not have figured out cold), explained the parts of the Facebook integration that weren't obvious from the code. Then I stopped pushing commits and the contract ended.
Two days later the scheduler broke.
Eric got back to me. I jumped on an hour-long call, screen-shared with the junior, walked through what the symptoms suggested and narrowed down where the regression must have come in. I gave them the most likely root causes, what to check first, what to roll back if a check confirmed it. They figured it out, rewrote the affected piece, and the server stayed up.
I wasn't paid for that hour. I didn't bring it up. It was the right thing to do given the relationship, and that hour is probably the closest single moment to what "leaving on good terms" actually means in practice.
Eric recorded a short video testimonial shortly after. The line that stuck with me:
The things that had hurdles or pushback, he was able to find solutions and workarounds that would be not only compliant within my Meta expectations, but also everything that I wanted to do in the app.
That's a generous read. A lot of it was me explaining what we couldn't build and helping figure out what we could instead. But it's the read I'd want any future client to walk away with.
What it actually was
Ten months in. 100+ users on launch. Around 500 pages under management. Roughly 10,000 posts scheduled a month. One engineer the whole way through.
Looked at from the outside, that's a respectable freelance contract on a real SaaS. Looked at from the inside, it was: a lot of debugging a platform API that doesn't want you debugging it; one scheduler decision I'd make the same way again; one auth decision I'd make the same way again; about a dozen feature decisions where I'd weigh scope-creep risk differently next time; and one client relationship that taught me what the upper end of "good client" actually looks like.
PostChart is at postchart.com. I'm not on it anymore, but the parts I shipped are.