<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: skedcast</title>
    <description>The latest articles on DEV Community by skedcast (@skedcast_ae8cf6ce056b0190).</description>
    <link>https://dev.to/skedcast_ae8cf6ce056b0190</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4135896%2Fe821c8b3-b61f-460f-8dd4-f3733a1f28ce.png</url>
      <title>DEV Community: skedcast</title>
      <link>https://dev.to/skedcast_ae8cf6ce056b0190</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/skedcast_ae8cf6ce056b0190"/>
    <language>en</language>
    <item>
      <title>How we stop a bulk social scheduler from double-posting, hammering rate limits, or losing posts</title>
      <dc:creator>skedcast</dc:creator>
      <pubDate>Mon, 21 Sep 2026 19:01:38 +0000</pubDate>
      <link>https://dev.to/skedcast_ae8cf6ce056b0190/how-we-stop-a-bulk-social-scheduler-from-double-posting-hammering-rate-limits-or-losing-posts-5egp</link>
      <guid>https://dev.to/skedcast_ae8cf6ce056b0190/how-we-stop-a-bulk-social-scheduler-from-double-posting-hammering-rate-limits-or-losing-posts-5egp</guid>
      <description>&lt;p&gt;Full disclosure: I build SkedCast, a bulk social media scheduler for agencies. This post is about the boring reliability problems underneath a publishing queue, because they are more interesting than the calendar UI. Nothing here is specific to our product; the same ideas apply to any system that pushes user content to rate-limited third-party APIs.&lt;/p&gt;

&lt;p&gt;The job sounds simple: at 09:00, publish this post to N accounts. In practice you have to answer four questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What happens when the same request arrives twice?
&lt;/h2&gt;

&lt;p&gt;Networks retry. Agents retry. Users double-click. If "create post" is not idempotent, a retry becomes a duplicate on someone's brand account, which is the one bug a scheduler cannot afford.&lt;/p&gt;

&lt;p&gt;We layer it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The public &lt;code&gt;POST /v1/posts&lt;/code&gt; endpoint &lt;strong&gt;requires&lt;/strong&gt; an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. Records are kept for 24 hours. Repeating a key while the first request is still running returns &lt;code&gt;409&lt;/code&gt;. Reusing a key with a different body returns &lt;code&gt;422&lt;/code&gt;, so a client bug cannot silently overwrite an earlier request.&lt;/li&gt;
&lt;li&gt;The database backs it up with a unique constraint on &lt;code&gt;(agency_id, idempotency_key)&lt;/code&gt;, so even if the application layer is bypassed, a second row cannot exist.&lt;/li&gt;
&lt;li&gt;Every publish target has its own idempotency key, and we use it as the &lt;strong&gt;BullMQ job id&lt;/strong&gt;. BullMQ ignores &lt;code&gt;add()&lt;/code&gt; for a job id that already exists, so enqueueing the same target twice is a no-op instead of a double publish.&lt;/li&gt;
&lt;li&gt;Bulk creation derives each item's key from &lt;code&gt;sha256(requestKey:index)&lt;/code&gt;, so a retried bulk import re-creates nothing it already created.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One gotcha worth knowing: BullMQ job ids cannot contain colons, so keys need normalizing (we swap them for underscores) before they are used as ids.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. How do you avoid looking like a bot without random behaviour?
&lt;/h2&gt;

&lt;p&gt;If you publish the same caption to five Instagram accounts at exactly 09:00:00, platforms notice. The obvious fix is random delay, but random delay makes retries and tests non-deterministic: a retried job would land at a different time than the first attempt.&lt;/p&gt;

&lt;p&gt;We use a &lt;strong&gt;deterministic jitter&lt;/strong&gt; instead: &lt;code&gt;fnv1a32(accountId + " " + staggerIndex) % (window + 1)&lt;/code&gt;. The same account and position always get the same offset, it is never negative, and it involves no &lt;code&gt;Math.random&lt;/code&gt;. A retry recomputes the exact same schedule.&lt;/p&gt;

&lt;p&gt;The scheduler then applies these steps in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start from the later of the requested time and now.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;staggerIndex * minSpacing&lt;/code&gt; when spreading is on.&lt;/li&gt;
&lt;li&gt;Push to at least &lt;code&gt;lastPublishedAt + minSpacing&lt;/code&gt; for that account (always applied).&lt;/li&gt;
&lt;li&gt;Add the jitter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each platform has its own defaults for daily cap and minimum interval (for example TikTok is far stricter than Bluesky), and they can be overridden per account.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What if the platform says "slow down"?
&lt;/h2&gt;

&lt;p&gt;Rate limits are not failures. We give each platform its own queue (&lt;code&gt;publish:&amp;lt;platform&amp;gt;&lt;/code&gt;) so a stuck platform cannot starve the others, and a per-account lease so two workers never publish to one account at once.&lt;/p&gt;

&lt;p&gt;The important part is that limits are checked &lt;strong&gt;twice&lt;/strong&gt;: at compose time, and again by the worker at publish time. Between scheduling and publishing, the world changes (another post went out, a cap was hit). If the worker finds the daily cap or spacing exceeded, it defers the target to a &lt;code&gt;rate_limited&lt;/code&gt; state rather than failing it, and it waits for the platform's &lt;code&gt;Retry-After&lt;/code&gt; when there is one (falling back to 15 minutes). A daily-cap defer waits until the next UTC midnight.&lt;/p&gt;

&lt;p&gt;Provider errors are classified rather than blindly retried:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rate limited: park and wait&lt;/li&gt;
&lt;li&gt;expired token: refresh, then retry&lt;/li&gt;
&lt;li&gt;transient: retry (3 attempts, exponential backoff from 30 s)&lt;/li&gt;
&lt;li&gt;bad request or indeterminate: fail immediately, because retrying a rejected payload only repeats the rejection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exhausted retries land in a dead-letter queue and fire a &lt;code&gt;post.failed&lt;/code&gt; webhook, so a person or an integration finds out.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. How do you know it actually went out?
&lt;/h2&gt;

&lt;p&gt;This is the part where I want to be careful, because "we confirm it's live" is an easy claim to overstate.&lt;/p&gt;

&lt;p&gt;Some platforms let us hand over a post with a future time (platform-native scheduling). For those, a periodic sweep polls the platform to check the post still exists and moves it to &lt;code&gt;published&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. TikTok has its own status-fetch poll. Anything stuck in platform-side processing for 24 hours becomes &lt;code&gt;indeterminate&lt;/code&gt; instead of pretending to be fine.&lt;/p&gt;

&lt;p&gt;For ordinary immediate publishes we rely on the platform's API response and reconcile before retrying, rather than running a separate "is it visible" check. Knowing exactly where your guarantees stop is more useful than a vague "verified" badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine
&lt;/h2&gt;

&lt;p&gt;Explicit states keep all of this debuggable: &lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;scheduled&lt;/code&gt;, &lt;code&gt;preparing&lt;/code&gt;, &lt;code&gt;publishing&lt;/code&gt;, &lt;code&gt;native_scheduled&lt;/code&gt;, &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;rate_limited&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;. Every failure path ends in a named state plus a webhook event, never a silent drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make idempotency a database constraint, not just middleware.&lt;/li&gt;
&lt;li&gt;Prefer deterministic jitter to random jitter.&lt;/li&gt;
&lt;li&gt;Re-check limits at execution time, not only at scheduling time.&lt;/li&gt;
&lt;li&gt;Separate "rate limited" from "failed".&lt;/li&gt;
&lt;li&gt;Write down precisely what you verify and what you don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to poke at the API or MCP server behind this, the docs are at &lt;a href="https://skedcast.com/developers" rel="noopener noreferrer"&gt;https://skedcast.com/developers&lt;/a&gt;. Happy to answer questions about any of the design choices in the comments.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>node</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
