Social Media Scheduling API: Queues, Time Zones and How to Build One
How a scheduled post actually fires: a 10-second claim loop with SKIP LOCKED, a per-organization in-flight cap, the three mechanisms that block a repeat publish plus the one window none of them close, and what to build when there is no recurrence field.
Scheduling a post looks like one timestamp in an API request. The problems start when two workers read that timestamp at the same second, or when TikTok accepts an upload and then spends the next hour processing it.
This is the scheduler half of our stack, written from the code that runs it: how a due post gets claimed, how we keep one customer's bulk import off everyone else's 09:00, and what our retries do and don't cover. Publishing itself, the four incompatible publish flows behind one endpoint, is in the social media posting API guide. Numbers here were checked against our own source on 25 August 2026.
TL;DR
- A loop ticks every 10 seconds, claims up to 500 due posts with
FOR UPDATE SKIP LOCKED, and flips them toPROCESSINGin the same transaction. No per-post timers anywhere. - One organization can hold 100 posts in flight. That cap is what stops a 40,000-post import from delaying everyone else's 09:00.
postDateis an instant, stored as UTC and compared tonow(). We also store the user's IANA time zone, but only to render dates in the dashboard.- Repeat execution is blocked by a workflow id, the database claim and a per-platform published check. Where those can't close the window, we don't retry at all, which is why Reddit and Facebook video get a single attempt.
- There's no recurrence field. You expand an RFC 5545 rule into concrete posts on your side, and there's a materializer below to copy.
- You can schedule 10 minutes into the past, and there's no limit on how far ahead.

Four questions every scheduler answers
Whether you buy one or build it, these get answered somewhere. Vendors differ mainly in whether they tell you which answer they picked.
| Question | The cheap answer | What it costs later |
|---|---|---|
| When is a post due? | Compare post_date to now() | Nothing, until two workers compare at the same moment |
| Who publishes it? | Whoever picks it up first | Two workers pick it up, the customer gets two posts |
| What if the worker dies mid-publish? | It will retry | A retry on a non-idempotent publish is a second post |
| What if the platform is slow? | Time out and fail | TikTok can take hours, and a timeout reports a failure that never happened |
The first two are scheduling. The last two are why we treat scheduling and publishing as separate problems even though they sit behind one endpoint.
postDate: one column, two meanings
An instant is a fixed point on the timeline. 2026-09-01T07:00:00Z is the same moment everywhere, and it's what the API accepts and what the scan compares against.
A wall clock is what somebody means when they say "9am". 9am in Warsaw on 1 September and 9am in Warsaw on 1 November sit an hour apart in UTC, because the clocks moved in between. A wall clock only becomes an instant once you apply a zone to it, and we leave that conversion to you: the client turns its date picker into .toISOString() and sends the result.
One detail from our schema, in case you're building the same thing. The column is timestamp without time zone, not timestamptz. Everything written into it is a UTC instant and the due check is post_date <= now(), so Postgres does that comparison in the session time zone. Ours runs in UTC. If yours doesn't, the comparison quietly means something else, and starting from scratch today we'd use timestamptz and delete the assumption.
Two boundary rules, both deliberate:
// Scheduling into the recent past is allowed, on purpose. export const SCHEDULED_POST_PAST_DATE_GRACE_MS = 10 * 60 * 1_000; // 10 minutes
A post dated up to 10 minutes ago is accepted and goes out on the next tick, so within about ten seconds. Anything older gets "Scheduled date cannot be more than 10 minutes in the past". Rejecting a timestamp because it went three seconds stale in transit protects nothing. It produces a support ticket about clock skew, which is an expensive way to learn what time it is.
There's no minimum lead time and no maximum horizon. You can create a post for 09:00:03 and you can create one for 2031. What bounds it is the monthly creation quota, counted when the post is created rather than when it fires, so a year of content scheduled in January spends January's allowance. Worth copying if you meter your own product: count at creation, because counting at publish time means one January subscription buys somebody twelve months of posting.
Here is the whole scheduling surface, as a request:
curl -X POST "https://api.bundle.social/api/v1/post" \ -H "x-api-key: ${BUNDLE_API_KEY}" \ -H "content-type: application/json" \ -d '{ "teamId": "team_123", "title": "Tuesday launch", "status": "SCHEDULED", "postDate": "2026-09-01T07:00:00.000Z", "referenceKey": "launch-2026-09-01", "socialAccountTypes": ["LINKEDIN", "INSTAGRAM", "THREADS"], "data": { "LINKEDIN": { "text": "We shipped scheduled retries." }, "INSTAGRAM": { "text": "We shipped scheduled retries.", "type": "POST", "uploadIds": ["upl_1"] }, "THREADS": { "text": "We shipped scheduled retries." } } }'
status takes DRAFT or SCHEDULED on create; the rest of the enum is assigned by the system. referenceKey is your idempotency key for the create call. It's unique per organization, so a request you retried after a dropped connection comes back as a 409 instead of scheduling Tuesday twice.
Rescheduling is a PATCH on the same post and cancelling is a DELETE. DELETE on a scheduled post cancels it. DELETE on a post that already went out removes it from us and leaves it up on the platform.
Timer, cron or scan
Three ways to make a row fire at a time, and the trade-off isn't obvious until you've run them.
| Design | How it works | Where it breaks |
|---|---|---|
| Per-post timer | Enqueue a job with a delay equal to the wait | A post scheduled a year out is a year-long reservation in memory or Redis. Rescheduling means finding and cancelling the exact job. Broker restarts and migrations have to carry millions of pending delays |
| Cron | Every minute, publish what is due | Granularity is the interval, so a post at 09:00:30 goes out at 09:01. Overlapping runs need their own locking, and a slow run stacks on the next one |
| Scan loop | Tick, claim what is due, hand off, sleep | Needs a claim that is safe under concurrency, and a recovery path for anything claimed but never finished |
We run the scan loop, as a single long-lived workflow rather than a scheduled job. The reason is operational rather than architectural: at a ten-second interval, a scheduled job leaves roughly 8,640 closed executions a day to garbage-collect, while a loop that restarts itself once an hour leaves about 24.
The claim is ordered by post_date, which makes a composite index on (status, post_date) the difference between a cheap query and a filtered scan of every post you've ever created, every ten seconds.

One customer's backlog shouldn't be everyone's outage
ORDER BY post_date LIMIT 500 is correct, and it's also how you get paged at 09:05. One organization importing 40,000 posts with the same timestamp owns every batch until it drains.
So the claim carries a window function. Per organization, rank the due posts and keep only as many as fit under a cap of 100 in flight. The cap counts rows already in PROCESSING, which means a slow tenant throttles itself and nobody has to run a rate limiter per customer.
Two exceptions in there are worth copying. Posts waiting for TikTok to hand back a public post id don't count against the cap, because that wait can run for hours and a few slow videos would otherwise block an organization for the rest of the day. Banned and soft-deleted organizations are filtered out of the claim rather than failed, so their scheduled posts sit unclaimed and publish if the organization comes back.
What stops a post going out twice, and what doesn't
Three mechanisms block repeat execution, and each covers a path the others don't.
The workflow id is derived from the post id. Every publish starts as post-<postId>, so a second start while the first is running is rejected by the engine rather than by our code. The dedupe key is structural, so a bug in the caller can't route around it.
The database claim is one transaction. FOR UPDATE SKIP LOCKED selects due rows and the flip to PROCESSING commits with it, so a row leaves SCHEDULED once no matter how many scanner replicas are running. Postgres puts it plainly: any selected rows that cannot be immediately locked are skipped, so two replicas running the same query get disjoint sets. Without SKIP LOCKED you either serialize the scan and cap throughput at one worker, or you accept a race whose failure mode is a double post.
Before publishing to a platform, the activity checks whether that platform already recorded an external id for this post. This is what makes a retry safe when the first attempt succeeded on LinkedIn and failed on Instagram: LinkedIn gets skipped, Instagram gets retried.
Now the part that vendors usually skip. That check reads what we stored, so it can't see a post the platform created a moment before the worker died, because the id never got written. No database guard closes that window by itself. It closes only where the platform hands you something to hold: an idempotency key, or a lookup you can trust to find what you just created. Most of them do neither, so we don't promise exactly-once publishing across that gap, and neither should anyone else.
What we do instead is refuse to repeat where a repeat would duplicate. Reddit gets one attempt, because every /api/submit creates a new post. Facebook video gets one attempt, because start, chunk, finish and create run as a single step with no idempotency key. YouTube is the case that works: the resumable session URI is persisted before the bytes go up, so a retry continues that upload instead of creating a second video. Where the platform gives us nothing to hold, an error you can act on beats a duplicate you can't delete.
One mechanism that looks adjacent but isn't duplicate protection: the post.published webhook is delivered by its own activity, not by the one that finalizes the post. That's a delivery fix. A retry of the finalizing activity would no-op the status update, decide it hadn't finalized anything and never send the webhook. Splitting them makes the notification at-least-once, and says nothing about whether the post reached the platform twice.
bundle.social
One postDate, fifteen platforms, and the retry rules already written down per platform.
Queue a whole calendar and let the scheduler publish it, platform by platform.
Time zones
We store a user's time zone, validated as a real IANA name rather than an offset string, and use it for exactly one thing: formatting dates in the dashboard. It never takes part in deciding when a post fires.
For an API consumer that's usually the right split, since you own the conversion and you're the one who knows what your user meant. It stops being enough the moment you build "every Monday at 09:00 local", and then three things matter.
Keep the zone next to the rule, not next to the timestamp. { rule: "every Monday 09:00", zone: "Europe/Warsaw" } survives a DST change. A precomputed list of UTC instants doesn't, and neither does a saved offset, because +02:00 describes one moment rather than a place.
Convert as late as you can, so the conversion uses whatever the tz database says at that point. Zone rules change by legislation, several times a year, sometimes with a few weeks of notice.
Decide what 09:00 means on the days it doesn't exist. When clocks spring forward, 02:30 local happens zero times; when they fall back, it happens twice. Pick skip, shift forward or first occurrence, write it down, and test it, because most date libraries pick for you without mentioning it.
import { fromZonedTime } from "date-fns-tz"; // the 9am the user meant, in the zone they meant it in const postDate = fromZonedTime("2026-11-02 09:00", "Europe/Warsaw").toISOString(); // send this; the scheduler never has to know about Warsaw

Recurring posts without a recurrence field
We don't have one. No RRULE, no cron per post, no repeat-weekly flag: one post, one row, one date.
That's a real limitation, and it's also a line we'd draw in the same place again, because recurrence carries decisions that belong to your product rather than to a publishing API. What happens on a DST-ambiguous occurrence. What happens to occurrences missed while your integration was down. Whether occurrence 20 is the same content as occurrence 1 or a fresh draft. We'd be guessing at all three.
The pattern that works is a materializer. Keep the rule on your side, expand it into a bounded window of real posts, and let the scheduling API deal with instants only.
import { RRule } from "rrule"; import { fromZonedTime } from "date-fns-tz"; const HORIZON_DAYS = 60; async function materialize(series) { const rule = RRule.fromString(series.rrule); // e.g. FREQ=WEEKLY;BYDAY=MO;BYHOUR=9 const until = new Date(Date.now() + HORIZON_DAYS * 864e5); for (const wallClock of rule.between(new Date(), until)) { // rrule returns floating local time, so the zone conversion is yours to do const postDate = fromZonedTime(wallClock, series.zone).toISOString(); await bundle.post.create({ ...series.template, status: "SCHEDULED", postDate, // one occurrence, one key, so re-running this costs nothing referenceKey: `${series.id}:${postDate}`, }); } }
Two properties make that safe to run on a schedule of its own. The horizon is bounded, so a rule with no UNTIL can't create infinite rows. And the reference key comes from the occurrence itself, so re-running after a deploy, a crash or a tz database update creates nothing new: everything that already exists comes back as a 409.
Catch-up stays your decision. If the materializer was down for a week, you're the one who decides whether those four missed Mondays go out late or get dropped, and support needs that written down somewhere.
When it goes wrong
Each platform publishes in its own activity, so a failure on one doesn't fail the whole post. Most platforms get three attempts in total: the first, a second three minutes later, and a third about nine minutes in, since the interval doubles. Byte uploads start their retries at 30 seconds, because resuming an upload is safe. Reddit and Facebook video get one attempt and no retries, for the reasons above.
A platform that runs out of attempts returns an ERROR for itself, and its siblings still finalize. One post can be live on LinkedIn and failed on Instagram, and it stays one row with a per-platform error map instead of two half-finished jobs.
Anything left in PROCESSING or RETRYING for more than 60 minutes is re-claimed by the same scan that picks up new posts, exempt from the per-organization cap. It's the only stuck-post recovery path in the system, and it leans on the published check above: re-driving a post that was mid-publish is only reasonable because the platforms that already have an external id get skipped.
A post in ERROR can be retried by hand up to 6 times. The ceiling is there because a failure that survived the automatic attempts is usually terminal: a revoked token, a page role somebody removed, a daily cap you already hit. About 7% of failed publishes never recover. For the rest, the median time to success is around three minutes, which is to say the first automatic retry got it.
If the start itself fails, the post is compare-and-set back to SCHEDULED rather than left in limbo, and the next tick picks it up.
Platform caps the scheduler has to respect
A scheduler that ignores platform limits just moves the failure from your queue to the platform's rejection. Daily per-account publishing caps we enforce before anything is queued:
| Platform | Free | Pro | Business |
|---|---|---|---|
| X (Twitter) | 5 | 15 | 15 |
| 10 | 50 | 100 | |
| 10 | 50 | 100 | |
| 10 | 18 | 24 | |
| YouTube | 10 | 10 | 15 |
| TikTok | 5 | 10 | 15 |
| Threads | 10 | 200 | 250 |
| 10 | 24 | 36 | |
| 10 | 24 | 36 | |
| Bluesky | 10 | 50 | 100 |
| Snapchat | 5 | 20 | 40 |
These count per social account per calendar day (UTC) against the post's target date, so forward-scheduling is checked when you create the post instead of being discovered at publish time. They sit on top of each platform's own ceilings rather than instead of them: Instagram's rolling 24-hour publishing cap, TikTok's per-minute and per-day limits, X's per-app and per-user windows. The full cross-platform picture, including the four different counting models platforms use, is in social media API rate limits.
Asynchronous platforms also need a scheduler that treats waiting as a state rather than as a timeout. Container-based publishing on Instagram and Threads needs a poll between create and publish: ours runs 15s, 15s, 20s against a 10-minute budget. TikTok needs far longer, so that poll stretches 30s, 60s, 2m, 5m, 15m, 30m, 1h, 4h against a 24-hour budget, interruptible early by the platform webhook. Those waits run as durable timers rather than sleeping workers, so a deploy in the middle of a four-hour wait costs nothing.
What to test before you trust a scheduling API
The same checks work on a vendor and on your own implementation:
- Schedule two posts for the same second on different accounts. Both should publish on time. If one is minutes late, you've found a serialized scan.
- Schedule a post, then move it five minutes earlier. It should fire at the new time. Per-post timer implementations often fire at the old one.
- Schedule 02:30 on a DST transition night in a zone that observes one, and ask what the documented behaviour is. "It just works" isn't an answer to a question with three valid ones.
- Ask what happens when a worker dies between the platform call and the confirmation. You want to hear about an idempotency key, a published check, or a platform where they don't retry at all. "We retry" on its own is the wrong answer.
- Ask for the recovery window. How long can a post sit stuck before anything notices? Ours is 60 minutes.
Frequently asked questions
What is a social media scheduling API?
An API that takes a post plus a future timestamp and publishes it at that time to every platform you targeted. The surface is small: create with a postDate and a SCHEDULED status, patch to reschedule, delete to cancel. What you're buying underneath is the claim, the fairness cap, the retry policy and the duplicate protection.
How accurate is scheduled publishing?
Ours is bounded by the scan interval, so 10 seconds, plus however long the platform takes to accept the post. On asynchronous platforms accepting and publishing are two different moments: the post sits in a waiting state on our side, and TikTok in particular can stay pending for hours. Second-level precision isn't available on a platform that fetches your media on its own schedule, whoever you buy from.
Can two workers publish the same post twice?
Not through the paths we control: the claim uses FOR UPDATE SKIP LOCKED, the workflow id is derived from the post id, and each platform is skipped if it already has an external id. The window nobody can close from the database side is a crash between the platform accepting the post and us recording it, which is why platforms with no idempotency key get a single attempt instead of an optimistic retry.
Does the API support recurring posts?
No. One post, one row, one date. You expand an RFC 5545 rule into concrete scheduled posts inside a bounded horizon, with a reference key per occurrence so the expansion is safe to re-run. The materializer above is the shape we'd recommend.
What happens if a scheduled post fails?
The platforms that succeeded stay published. The ones that failed get up to three attempts (one for Reddit and Facebook video), then the post lands in ERROR with a per-platform error map. You can retry it by hand up to six times, and the post.published webhook fires on the terminal state either way, so your side doesn't have to poll to find out.