<?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: John Builds</title>
    <description>The latest articles on DEV Community by John Builds (@johnbuilds).</description>
    <link>https://dev.to/johnbuilds</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%2F3889099%2F518139a0-3766-412c-85c6-85c0b587fbd3.jpg</url>
      <title>DEV Community: John Builds</title>
      <link>https://dev.to/johnbuilds</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/johnbuilds"/>
    <language>en</language>
    <item>
      <title>The bug report said 6 fields. The contract test said 13.</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Thu, 20 Aug 2026 17:10:34 +0000</pubDate>
      <link>https://dev.to/johnbuilds/the-bug-report-said-6-fields-the-contract-test-said-13-47oo</link>
      <guid>https://dev.to/johnbuilds/the-bug-report-said-6-fields-the-contract-test-said-13-47oo</guid>
      <description>&lt;p&gt;We had one API input schema declared twice. A Zod schema validated requests on the npm package path. A hand-written JSON Schema advertised the same input on a serverless worker path. Same tool, two deployment targets, two copies of the truth, kept in sync by discipline alone.&lt;/p&gt;

&lt;p&gt;Zod strips unknown keys by default. No error, no warning. Anything the schema does not recognize is deleted before your handler ever sees it. So when the two copies drifted, a documented field sent down the npm path was quietly removed in flight. The response looked like a success. The data was just gone. The exact same call against the worker path worked, because that copy of the schema knew the field.&lt;/p&gt;

&lt;p&gt;A bug report came in after someone read both schemas side by side, carefully. It listed 6 drifted fields.&lt;/p&gt;

&lt;p&gt;While fixing it I wrote the test that should have existed from the start: import both schemas, extract the key sets, assert they match. It failed with 13 fields. One of them was being silently stripped in production and nobody had reported it, because the failure mode is a success response. The other 12 existed only in the validator, so they were invisible to anyone reading the advertised schema.&lt;/p&gt;

&lt;p&gt;The part that actually stung: the file had a comment saying both copies must be kept in sync. It had been there through all 13 drifts. Comments don't run.&lt;/p&gt;

&lt;p&gt;What I keep from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When the invariant is "two lists must match", never enumerate the drift by hand. A careful human read found less than half of it. Diff the lists in a test and let the failure tell you the scope.&lt;/li&gt;
&lt;li&gt;A validator that strips unknown keys turns schema drift into silent data loss. Treat it as an allowlist that deletes whatever it does not know.&lt;/li&gt;
&lt;li&gt;The two drift directions fail differently. A key missing from the validator is a runtime data-loss bug. A key missing from the advertised schema is merely undiscoverable. Report them separately.&lt;/li&gt;
&lt;li&gt;On any system with two deployment paths, "I tested it" means nothing until you say which path.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>typescript</category>
      <category>api</category>
    </item>
    <item>
      <title>The same failed payment destroyed one customer's data and left the next one's alone</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:35:26 +0000</pubDate>
      <link>https://dev.to/johnbuilds/the-same-failed-payment-destroyed-one-customers-data-and-left-the-next-ones-alone-pek</link>
      <guid>https://dev.to/johnbuilds/the-same-failed-payment-destroyed-one-customers-data-and-left-the-next-ones-alone-pek</guid>
      <description>&lt;p&gt;We had a bug that only fired sometimes, and "sometimes" turned out to be a coin flip on webhook arrival order.&lt;/p&gt;

&lt;p&gt;Here's the setup. Our billing code had two constants. One listed the statuses that still grant access. One listed the statuses that trigger destructive cleanup: reverting scheduled work to drafts, revoking API keys, turning features off.&lt;/p&gt;

&lt;p&gt;The status a payment processor sets on a &lt;em&gt;first&lt;/em&gt; failed invoice was in both lists. Same string, opposite meanings, four lines apart in the same file.&lt;/p&gt;

&lt;p&gt;That alone is a bug. What kept it alive was the ordering.&lt;/p&gt;

&lt;p&gt;The cleanup path was guarded by "was this subscription active before this webhook?" Two webhooks arrive for one failed payment, and the processor does not promise which lands first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Subscription-updated first: prior status still reads active, guard passes, cleanup runs, the customer's scheduled times are nulled out.&lt;/li&gt;
&lt;li&gt;Payment-failed first: that handler sets the status directly, so by the time subscription-updated lands the guard already reads false, and cleanup never runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same event, same code, two outcomes decided by network timing. It never reproduced consistently enough for anyone to chase it.&lt;/p&gt;

&lt;p&gt;Then I found the test that was protecting it. A spec looped over the "inactive" statuses asserting each one enqueued the cleanup job. Green suite. Fixing the code turned that spec red, because the spec had encoded the wrong behavior as the requirement.&lt;/p&gt;

&lt;p&gt;Three things I took from it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A constant used for access control and a constant used for destructive cleanup must never share a member.&lt;/strong&gt; If they have to, the shared member needs a comment explaining why. Grep every consumer before you add a status to a list that does irreversible work. Ours had exactly one consumer, so the check cost nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any handler whose behavior depends on the record's prior state is ordering-dependent.&lt;/strong&gt; Delivery is guaranteed. Order is not. Derive the guard from the payload, or make the handler idempotent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cleanup that nulls a column is not undone by re-activating.&lt;/strong&gt; Gating access is reversible. Destroying data is not, so it belongs at a terminal state only.&lt;/p&gt;

&lt;p&gt;A grace period is not a terminal state. We were treating "the first payment failed and we're going to retry for a few days" as "this person is gone."&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>testing</category>
      <category>debugging</category>
      <category>billing</category>
    </item>
    <item>
      <title>Best Free Social Media Management Tools</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Sat, 15 Aug 2026 15:15:37 +0000</pubDate>
      <link>https://dev.to/johnbuilds/best-free-social-media-management-tools-13f0</link>
      <guid>https://dev.to/johnbuilds/best-free-social-media-management-tools-13f0</guid>
      <description>&lt;p&gt;The best free social media management tools in 2026 are Buffer, Meta Business Suite, Zoho Social, Publer, Metricool, and XreplyAI. Each is genuinely free forever, and each caps a different thing: channels, monthly posts, or queue depth.&lt;br&gt;
"Free" is doing a lot of work in this category. Some free plans run forever with real limits printed on the pricing page. Others are a 14-day trial wearing a costume, and you only find out on day 15.&lt;/p&gt;

&lt;p&gt;The roundups that rank for this query mostly list twelve tools and bury the caps inside per-tool prose. That is the wrong shape for the actual decision. You are not choosing between logos, you are choosing which ceiling you hit first: the third channel, the tenth queued post, or the second person on your team.&lt;/p&gt;

&lt;p&gt;So this post starts with the structure of free plans, then names tools, then covers the cost that nobody puts on the comparison chart. Every number below came off the vendor's own pricing page on 2026-08-08, because these change within weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "free" actually mean in a social media tool?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Free plans come in three shapes: free forever with hard caps, freemium where the free tier exists to funnel you to paid, and a trial dressed up as a free plan.&lt;/p&gt;

&lt;p&gt;Free forever with caps is the honest version. Buffer, Publer, Zoho Social, and Metricool all publish a $0 tier with numbers next to it. You can sit on that tier indefinitely and the tool keeps working, just within a fence.&lt;/p&gt;

&lt;p&gt;Trial-shaped free is the version that costs you a migration. The plan reads as free on the marketing page, and the fine print reveals a 14-day window after which publishing stops. Zoho Social is a useful hybrid here: its free tier has lifetime validity, but only after a 15-day trial of the fuller feature set expires, so your first two weeks are not representative of what you keep.&lt;/p&gt;

&lt;p&gt;The third shape is freemium-with-a-funnel, where the free tier is deliberately sized just below the point of usefulness. One channel when you post to three. Twenty posts a month when you post daily. It works, technically, and it is engineered to stop working the week you get serious.&lt;/p&gt;

&lt;p&gt;Here is the pattern underneath all of it. Vendors give away the thing that costs them nothing and gate the thing that costs them API calls, storage, or seats.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Typically free&lt;/th&gt;
&lt;th&gt;Typically paywalled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Composing and drafting posts&lt;/td&gt;
&lt;td&gt;Yes, usually unlimited&lt;/td&gt;
&lt;td&gt;Rarely gated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connected channels&lt;/td&gt;
&lt;td&gt;1 to 3&lt;/td&gt;
&lt;td&gt;Anything above 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled posts in queue&lt;/td&gt;
&lt;td&gt;10 to 30, or 20 per month&lt;/td&gt;
&lt;td&gt;Unlimited queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content calendar view&lt;/td&gt;
&lt;td&gt;Usually yes&lt;/td&gt;
&lt;td&gt;Rarely gated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics history&lt;/td&gt;
&lt;td&gt;24 hours to 30 days&lt;/td&gt;
&lt;td&gt;Full history and exports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Additional users or seats&lt;/td&gt;
&lt;td&gt;Almost never&lt;/td&gt;
&lt;td&gt;Always, and priced per seat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI generation&lt;/td&gt;
&lt;td&gt;A small credit allowance&lt;/td&gt;
&lt;td&gt;Ongoing usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk upload and automation&lt;/td&gt;
&lt;td&gt;Almost never&lt;/td&gt;
&lt;td&gt;Always&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table as a prediction. Whatever tool you pick, the seat and the queue are where you will meet the paywall, in that order. If you want the scheduling-only view of this same question, our &lt;a href="https://xreplyai.com/blog/free-social-media-scheduler" rel="noopener noreferrer"&gt;free social media scheduler&lt;/a&gt; breakdown goes deeper on queue mechanics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free tools worth using in 2026
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Six tools have free tiers that a solo founder can actually run on, and each one is the right answer for a different posting pattern.&lt;/p&gt;

&lt;p&gt;All figures below are from each vendor's own pricing or help page, checked 2026-08-08. Treat them as perishable. For the paid side of this comparison, see our roundup of the &lt;a href="https://xreplyai.com/blog/best-social-media-management-tools" rel="noopener noreferrer"&gt;best social media management tools&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Buffer
&lt;/h3&gt;

&lt;p&gt;Free plan: 3 channels, 10 scheduled posts per channel, 1 user account, 30-day analytics history, 100 ideas. The per-channel refill is the smart part, since 10 posts per channel across 3 channels is 30 queued posts, not 10.&lt;/p&gt;

&lt;p&gt;Best for: someone posting a few times a week across three networks who wants the cleanest interface in the category. The catch: 1 user, hard stop, and Buffer's paid pricing is per channel, so growth is metered on the axis you are most likely to grow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Meta Business Suite
&lt;/h3&gt;

&lt;p&gt;Free, no tier structure, no caps published on scheduling. Covers Facebook and Instagram only.&lt;/p&gt;

&lt;p&gt;Best for: anyone whose audience genuinely lives on Facebook and Instagram. If that is you, stop reading and use this. It is free, it is first-party, it never rate-limits your own Pages, and no third-party tool will out-feature Meta on Meta. The catch is obvious: two platforms, and you are back to tab-switching the moment LinkedIn or X matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zoho Social
&lt;/h3&gt;

&lt;p&gt;Free plan: 1 brand covering 6 channels (X, Instagram, Facebook Page, LinkedIn Company Page, LinkedIn Profile, Google Business Profile), 1 team member, 5 AI credits, lifetime validity after a 15-day trial.&lt;/p&gt;

&lt;p&gt;Best for: the widest free channel coverage on this list, and the only free tier here that includes both X and a Google Business Profile. The catch: it is one brand and one seat, and Zoho Social is a component of a much larger suite, so the interface assumes you are somewhere in the Zoho ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Publer
&lt;/h3&gt;

&lt;p&gt;Free plan: 3 social accounts, 10 pending scheduled posts per account, 25 saved drafts, 24 hours of post history. X is excluded from the free tier entirely, which Publer attributes to Twitter Enterprise API cost.&lt;/p&gt;

&lt;p&gt;Best for: visual-platform posting where you want drafts and a real calendar without paying. The catch is the 24-hour history: you lose your own publishing record almost immediately, which makes any retrospective impossible on free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metricool
&lt;/h3&gt;

&lt;p&gt;Free plan: 1 brand, 20 scheduled posts per month, 5 competitor profiles analyzed, 30 days of analytics. LinkedIn and X are excluded from the free brand's networks.&lt;/p&gt;

&lt;p&gt;Best for: analytics-first users. The competitor tracking on a free tier is genuinely unusual. The catch: 20 posts a month is under one post a day, so Metricool free is a measurement tool that also schedules, not a scheduler that also measures.&lt;/p&gt;

&lt;h3&gt;
  
  
  XreplyAI
&lt;/h3&gt;

&lt;p&gt;Free plan: manually create, edit, schedule, and publish to up to 3 connected channels, 30 posts. All 15 platforms are available to pick from, including Bluesky, Mastodon, Threads, Discord, Telegram, Google Business, and Slack. No credit card required.&lt;/p&gt;

&lt;p&gt;Best for: someone whose audience is spread across platforms the bigger tools treat as afterthoughts, and who wants one calendar rather than three tabs. The catch, stated plainly: AI generation is paid-gated apart from 10 lifetime AI generations, so if you came looking for a free AI writer, this is not it. Free here means free scheduling and publishing, and the writing is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit that actually bites: per-seat pricing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Every free plan on this list is a single seat, so the moment a second person touches your social accounts you are not upgrading a plan, you are starting a per-person bill.&lt;/p&gt;

&lt;p&gt;Count the seats in the section above. Buffer: 1 user account. Zoho Social: 1 team member. Metricool and Publer: 1 brand or workspace. That is the category's pricing model showing through the free tier.&lt;/p&gt;

&lt;p&gt;The comparison charts rarely price this out. Channel caps and post caps are annoying and linear: you hit 3 channels, you pay for more channels, and the cost tracks something you can see. Seats are a step function tied to a decision you make for non-tool reasons. You hire a contractor, you bring in a co-founder, you hand social to an intern for the summer, and the tool's price changes because your org changed.&lt;/p&gt;

&lt;p&gt;A worked example, using real published numbers. Say you are two people posting to five channels. On Buffer's free plan you have 3 channels and 1 seat, so you are short on both axes. Paid Buffer meters channels, and the second person needs their own access, so your monthly cost is a function of channels multiplied by people. On XreplyAI's Starter tier at $11.99 a month you get 10 channels, and the workspace does not bill per person: our &lt;a href="https://xreplyai.com/pricing" rel="noopener noreferrer"&gt;flat pricing, no per-seat fees&lt;/a&gt; model prices the workspace, not the headcount.&lt;/p&gt;

&lt;p&gt;Here is a position you can reasonably disagree with. Most free plans in this category are sized to become painful at exactly the moment the tool starts working for you, and the seat cap is the sharpest instance of that. The honest question is never "which one is free," it is "which free plan's ceiling matches my actual volume, and what does the first upgrade cost when I cross it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you choose the right free plan?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; Count your channels, your weekly post volume, and your people, then pick the free plan whose tightest cap sits above all three numbers.&lt;/p&gt;

&lt;p&gt;Start with channel count, because it is the cap that eliminates tools fastest. If your list includes X, note that Publer excludes X from free and Metricool excludes X and LinkedIn from its free brand. If it includes Bluesky, Mastodon, Discord, or Telegram, most of this list drops out entirely.&lt;/p&gt;

&lt;p&gt;Then do the post-volume arithmetic honestly. Three posts a week across three channels is 36 posts a month, which clears Buffer's per-channel refill and blows straight past Metricool's 20-per-month cap. Daily posting on two channels is roughly 60 a month, which no free plan on this list holds without refilling manually.&lt;/p&gt;

&lt;p&gt;Third, decide whether a second person is coming in the next six months. If yes, evaluate the paid tier now instead of the free one, because migrating a scheduled queue between tools is a genuinely bad afternoon and free-tier seat caps do not negotiate.&lt;/p&gt;

&lt;p&gt;Fourth, look at what the free tier does to your history. Publer's 24-hour post history and Metricool's 30-day analytics window mean you cannot answer "what did we post last quarter" on free. If you need that record, that constraint outranks the post cap.&lt;/p&gt;

&lt;p&gt;Last, check the upgrade path before you commit, not after. A free plan is a trial of the paid plan you will eventually buy, so the number that matters is the second one on the pricing page. Our guide to &lt;a href="https://xreplyai.com/blog/best-social-media-scheduling-tools" rel="noopener noreferrer"&gt;social media scheduling tools&lt;/a&gt; covers that paid tier comparison in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What free social media tools cannot do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; No free plan in this category gives you bulk upload, unlimited queue depth, multiple seats, or full analytics history, and pretending otherwise sets you up for a mid-quarter migration.&lt;/p&gt;

&lt;p&gt;Bulk scheduling is universally paid. If your workflow is "sit down once a month and load 40 posts," every free tier here will fight you, because the queue caps are specifically sized to prevent that pattern.&lt;/p&gt;

&lt;p&gt;Approval workflows are paid everywhere, without exception. The moment someone needs to review before publish, you are on a paid tier, and usually a mid or upper one rather than the entry plan.&lt;/p&gt;

&lt;p&gt;Long analytics history is paid. Thirty days is the generous end of free, twenty-four hours is the tight end, and neither supports a quarterly review.&lt;/p&gt;

&lt;p&gt;Sustained AI generation is paid across the board, including ours. Free AI allowances are sampling credits: Zoho Social gives 5, Buffer gives 5 AI reply suggestions per week, XreplyAI gives 10 lifetime generations. They exist so you can see the output quality before you decide, not so you can run a content operation on them.&lt;/p&gt;

&lt;p&gt;None of that makes free plans a trap. It makes them a scoped tool. A free plan is the right call when your volume is genuinely low, when you are testing whether a posting habit sticks before paying for it, or when your platforms happen to line up with a first-party tool like Meta Business Suite. It is the wrong call when you already know your volume and you are choosing the free tier to avoid a $12 decision.&lt;/p&gt;

&lt;p&gt;Pick the free plan whose tightest cap sits above your real numbers, not the one with the longest feature list. If your audience is on Facebook and Instagram, Meta Business Suite is free and first-party and you should just use it. If you need six channels on one seat, Zoho Social has the broadest free coverage here.&lt;/p&gt;

&lt;p&gt;If your presence is scattered across platforms the big tools treat as afterthoughts, that is the gap XreplyAI was built for: 15 platforms from one calendar, 3 channels and 30 posts free with no credit card, and flat pricing with no per-seat fees when you outgrow it. &lt;a href="https://xreplyai.com" rel="noopener noreferrer"&gt;Start free at XreplyAI&lt;/a&gt; and see whether the calendar holds your actual posting rhythm before you pay anyone.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://buffer.com/pricing" rel="noopener noreferrer"&gt;Buffer pricing&lt;/a&gt; (checked 2026-08-08)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://publer.com/help/en/article/what-are-publers-plans-and-pricing-15h4yqh/" rel="noopener noreferrer"&gt;Publer plans and pricing, Publer Help Center&lt;/a&gt; (checked 2026-08-08)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://metricool.com/pricing/" rel="noopener noreferrer"&gt;Metricool pricing&lt;/a&gt; (checked 2026-08-08)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.zoho.com/social/pricing.html" rel="noopener noreferrer"&gt;Zoho Social pricing&lt;/a&gt; (checked 2026-08-08)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.facebook.com/business/tools/meta-business-suite" rel="noopener noreferrer"&gt;Meta Business Suite&lt;/a&gt; (checked 2026-08-08)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best free social media management tool?
&lt;/h3&gt;

&lt;p&gt;It depends on your platforms. Meta Business Suite wins if you only post to Facebook and Instagram. Zoho Social gives the widest free channel coverage at 6 channels. Buffer has the cleanest interface at 3 channels and 10 posts per channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are free social media management tools actually free forever?
&lt;/h3&gt;

&lt;p&gt;Some are. Buffer, Publer, Metricool, and XreplyAI publish permanent $0 tiers with hard caps. Zoho Social's free tier has lifetime validity but only after a 15-day trial of fuller features ends. Always check whether the plan is free forever or a trial.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many social accounts can you connect for free?
&lt;/h3&gt;

&lt;p&gt;Typically 1 to 3. Buffer, Publer, and XreplyAI allow 3. Zoho Social allows 1 brand covering 6 channels. Metricool allows 1 brand. Meta Business Suite covers your own Facebook and Instagram accounts with no third-party cap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you schedule posts to X for free?
&lt;/h3&gt;

&lt;p&gt;Rarely. Publer excludes X from its free plan and Metricool excludes X and LinkedIn from its free brand, both citing X API costs. Zoho Social and XreplyAI include X in their free tiers. Verify this before committing, since API pricing shifts often.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do free social media plans only allow one user?
&lt;/h3&gt;

&lt;p&gt;Because seats are how the category prices. A second user is not a bigger plan, it is a second bill. If you expect a teammate within six months, compare paid tiers now: some price per seat, others price the workspace at a flat rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a free plan enough for a solo founder?
&lt;/h3&gt;

&lt;p&gt;Often yes, at low volume. Three channels and roughly 30 queued posts covers posting a few times a week. It breaks down at daily posting across four or more platforms, or when you need analytics history beyond 30 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do free plans include AI content generation?
&lt;/h3&gt;

&lt;p&gt;Only as sample credits. Zoho Social includes 5 AI credits, Buffer offers 5 AI reply suggestions per week, and XreplyAI includes 10 lifetime AI generations. Ongoing AI writing is paid on every tool in this roundup.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does XreplyAI's free plan include?
&lt;/h3&gt;

&lt;p&gt;Manually create, edit, schedule, and publish to up to 3 connected channels, 30 posts, with no credit card required. All 15 platforms are selectable. AI generation is paid-gated apart from 10 lifetime AI generations.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://xreplyai.com/blog/free-social-media-management-tools" rel="noopener noreferrer"&gt;xreplyai.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>socialmedia</category>
      <category>productivity</category>
      <category>saas</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Picking the model that's already the right shape</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Fri, 14 Aug 2026 15:06:03 +0000</pubDate>
      <link>https://dev.to/johnbuilds/picking-the-model-thats-already-the-right-shape-35bi</link>
      <guid>https://dev.to/johnbuilds/picking-the-model-thats-already-the-right-shape-35bi</guid>
      <description>&lt;p&gt;A user reported that their weekly content plan could only post to one of their three Instagram accounts. I assumed a misconfiguration. It wasn't one. The planner selected a platform and then one account inside it, so "Instagram" resolved to exactly one Instagram account. The other two were unreachable and no setting could change it.&lt;/p&gt;

&lt;p&gt;Fixing it meant making the account the unit of targeting instead of the platform. Straightforward enough.&lt;/p&gt;

&lt;p&gt;The interesting decision was the second feature that came with it: letting several same-platform accounts share one generation, so one post body publishes to all of them.&lt;/p&gt;

&lt;p&gt;There were two places to model that. The obvious one was the per-account assignment table, which is where a plan records "this account gets a post at this time." But that table has a required single-account foreign key, a uniqueness constraint on account plus plan plus time, and 19 consumers. Widening it to many-accounts meant touching all of them and relaxing a constraint that was doing real work.&lt;/p&gt;

&lt;p&gt;The other option was a nullable group column on the channel row: channels in the same group generate together. The scheduled-post record downstream was already multi-account, because publishing the same post to several accounts has been supported for a long time. The capability existed, it just wasn't reachable from the planner.&lt;/p&gt;

&lt;p&gt;Modeling it on the channel meant one migration, one nullable column, and no change to the assignment table at all.&lt;/p&gt;

&lt;p&gt;So before widening a model to fit a new feature, check whether some other model in the system is already the right shape. Multi-account publishing was already solved one layer down. The planner just needed a way to say "these go together."&lt;/p&gt;

&lt;p&gt;One detail that fell out of the design: a group needs a single posting schedule, so the lowest account id in the group leads and the others follow its slots. Deterministic, no new column, and if that account disconnects the next one takes over.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://xreplyai.com/dashboard/content-planner?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=feature-2026-08-14" rel="noopener noreferrer"&gt;https://xreplyai.com/dashboard/content-planner?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=feature-2026-08-14&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>rails</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>iOS Universal Links follow redirects, and that quietly breaks OAuth</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Thu, 13 Aug 2026 17:49:24 +0000</pubDate>
      <link>https://dev.to/johnbuilds/ios-universal-links-follow-redirects-and-that-quietly-breaks-oauth-3nbe</link>
      <guid>https://dev.to/johnbuilds/ios-universal-links-follow-redirects-and-that-quietly-breaks-oauth-3nbe</guid>
      <description>&lt;p&gt;I lost a day to an OAuth bug that produced no errors, no logs, and no failed requests. On an iPhone with the provider's app installed, tapping "Connect" opened that app to its feed. No authorization dialog, no callback, nothing on the server. The flow just evaporated.&lt;/p&gt;

&lt;p&gt;My first theory was that the authorize URL was registered as a Universal Link, so iOS was routing the whole thing to the app. I shipped a parameter to force the web dialog. It changed nothing, because the parameter does not exist. I had picked it up from a URL I saw in the wild and assumed it did what its name suggested.&lt;/p&gt;

&lt;p&gt;The actual mechanism:&lt;/p&gt;

&lt;p&gt;The provider's apple-app-site-association file explicitly excludes &lt;code&gt;/oauth/authorize&lt;/code&gt; from Universal Links. They want the dialog in a browser. But if you have no web session with them, and almost nobody who lives in the app does, the authorize URL responds with a 302 to &lt;code&gt;/accounts/login&lt;/code&gt;. That path is not excluded. It matches the catch-all.&lt;/p&gt;

&lt;p&gt;And iOS evaluates Universal Links across the server redirects of a tap-initiated navigation, not just the URL you started with. So the excluded path handed off to a registered one, and the app swallowed the flow.&lt;/p&gt;

&lt;p&gt;The fix is not a provider parameter. Universal Links fire on tap-initiated navigations and not on programmatic ones. Open a blank popup synchronously inside the click handler, so the popup blocker stays happy, then navigate it with &lt;code&gt;location.assign&lt;/code&gt;. The whole redirect chain stays in the browser.&lt;/p&gt;

&lt;p&gt;Two things I would do differently. Check the provider's AASA file before theorizing about deep links; it is a public URL and it disproved my first theory for free. And follow the entire logged-out redirect chain with curl, not just the entry URL. An excluded path that redirects to a registered one still deep-links.&lt;/p&gt;

&lt;p&gt;I was certain the auth URL was broken. It was the login page it redirected to.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>oauth</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Dogfooding as QA: four months of publishing through our own scheduler</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:59:50 +0000</pubDate>
      <link>https://dev.to/johnbuilds/dogfooding-as-qa-four-months-of-publishing-through-our-own-scheduler-3fkg</link>
      <guid>https://dev.to/johnbuilds/dogfooding-as-qa-four-months-of-publishing-through-our-own-scheduler-3fkg</guid>
      <description>&lt;p&gt;In April I made a rule for myself: our product's own social accounts have to run on the product. No composers, no manual posting, no exceptions.&lt;/p&gt;

&lt;p&gt;I build a social media scheduler, so this should have been an easy call. It still took four months of shipping before I trusted it with our own presence. Since mid-April, our LinkedIn, X, Bluesky, and Threads accounts have published every weekday through the same pipeline we sell, and I have not opened a composer since.&lt;/p&gt;

&lt;p&gt;Two findings I did not expect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dogfooding is the best QA we have.&lt;/strong&gt; When a platform changes its API, our own morning post breaks first, before any customer's does. We have caught real publish bugs hours before a support ticket could have existed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consistency compounds quietly.&lt;/strong&gt; Nothing went viral. But every profile looks alive, and "looks alive" is the first thing a stranger checks before trusting you with anything.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable version of the lesson: if your product claims to save people time and you do not run your own operations on it, you are asking customers to believe something you never tested.&lt;/p&gt;

&lt;p&gt;The scheduler is XreplyAI: one calendar, 15 platforms. &lt;a href="https://xreplyai.com/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=edusales-2026-08-11" rel="noopener noreferrer"&gt;https://xreplyai.com/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=edusales-2026-08-11&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>indiehacker</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Adding usage-based pricing turned a UX decision into a billing bug</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Mon, 10 Aug 2026 20:50:46 +0000</pubDate>
      <link>https://dev.to/johnbuilds/adding-usage-based-pricing-turned-a-ux-decision-into-a-billing-bug-1l53</link>
      <guid>https://dev.to/johnbuilds/adding-usage-based-pricing-turned-a-ux-decision-into-a-billing-bug-1l53</guid>
      <description>&lt;p&gt;We shipped a model picker last week. Users choose which AI writes their posts, see the cost per model up front, and pay the provider's exact measured rate with no markup on top.&lt;/p&gt;

&lt;p&gt;Then we found a bug that could not have existed the week before.&lt;/p&gt;

&lt;p&gt;Our Generate button had always returned three drafts per press. It was a UX call made back when generation was free and unmetered: more options, one click, nothing to think about. Nobody revisited it.&lt;/p&gt;

&lt;p&gt;The moment a premium model sat behind that button, one press billed three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix is wrong
&lt;/h2&gt;

&lt;p&gt;The intuitive fix is to read which model the user selected and bill off that. It does not work, and the reason is the part worth stealing.&lt;/p&gt;

&lt;p&gt;What the user picked is not what actually runs. If their plan lapsed, or their prepaid balance hit zero, generation degrades to the included model and the run costs nothing. Bill off the stored preference and you charge people for generations that were never premium in the first place.&lt;/p&gt;

&lt;p&gt;So the check had to move. Ask the generation service what model it actually resolved to at runtime, then decide both how many drafts to produce and what to debit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;AiModelRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;premium?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ai_model_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:variants&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MIN_VARIANTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MAX_VARIANTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One press, one billed generation, and only when the thing that ran was genuinely the paid model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable part
&lt;/h2&gt;

&lt;p&gt;If you are adding usage-based pricing to an existing product, you are not adding a payment step. You are re-auditing every default the flow already had.&lt;/p&gt;

&lt;p&gt;Batch sizes. Retries. Auto-refresh. Preview renders. Anything that quietly multiplies work was free before and none of it is now. Each one was a UX decision when it was written, and every one of them silently became a billing decision the day money showed up.&lt;/p&gt;

&lt;p&gt;The second lesson is narrower but bit us harder: when a system can degrade or fall back, "what the user configured" and "what executed" are different values. Bill the second one. Log the second one. If your metering reads a stored preference anywhere, that is a bug waiting for someone's plan to lapse.&lt;/p&gt;

&lt;p&gt;We build XreplyAI, a scheduler for 15 platforms, if it matters: &lt;a href="https://xreplyai.com?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=feature-2026-08-10" rel="noopener noreferrer"&gt;https://xreplyai.com?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=feature-2026-08-10&lt;/a&gt;&lt;/p&gt;

</description>
      <category>billing</category>
      <category>saas</category>
      <category>api</category>
      <category>lessonslearned</category>
    </item>
    <item>
      <title>The most specific sentence in your draft is usually not the first line</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:19:32 +0000</pubDate>
      <link>https://dev.to/johnbuilds/the-most-specific-sentence-in-your-draft-is-usually-not-the-first-line-2cep</link>
      <guid>https://dev.to/johnbuilds/the-most-specific-sentence-in-your-draft-is-usually-not-the-first-line-2cep</guid>
      <description>&lt;p&gt;I spent a year writing posts that were technically true and completely unreadable.&lt;/p&gt;

&lt;p&gt;"Learned a lot about pricing this month." "Shipping has been going well." "Big lessons from the latest release." Every one is a summary of a post I never actually wrote.&lt;/p&gt;

&lt;p&gt;The habit that fixed it takes about ten seconds. Before publishing, find the most specific sentence in the draft. Then check whether it is the first line. It almost never is. It's usually sitting in paragraph three, hedged, after two paragraphs of setup.&lt;/p&gt;

&lt;p&gt;"Learned a lot about pricing" becomes "I raised my price 40% and two customers thanked me." Same month, same lesson, same person. One of them gets read.&lt;/p&gt;

&lt;p&gt;The test: could anyone else in your position have written this sentence? If yes, it's a category, not a detail. Go find the number, the quote, or the specific moment.&lt;/p&gt;

&lt;p&gt;Specificity also gives readers something to disagree with, which is where most replies come from.&lt;/p&gt;

&lt;p&gt;The buried line is usually the post.&lt;/p&gt;

</description>
      <category>writing</category>
      <category>contentcreation</category>
      <category>career</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Lessons from migrating a production Rails API from Render to Railway</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Wed, 05 Aug 2026 18:16:34 +0000</pubDate>
      <link>https://dev.to/johnbuilds/lessons-from-migrating-a-production-rails-api-from-render-to-railway-262i</link>
      <guid>https://dev.to/johnbuilds/lessons-from-migrating-a-production-rails-api-from-render-to-railway-262i</guid>
      <description>&lt;p&gt;Moved a production Rails API from Render to Railway recently. Web service, Solid Queue worker, Postgres 18. Here are the lessons worth stealing, whichever direction you're migrating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suspend the old services. Do not delete them.
&lt;/h2&gt;

&lt;p&gt;Suspending costs nothing and it is the entire rollback plan. On cutover night we suspended the old worker, then the old web service, and left both sitting there. If the restore had gone badly we could have brought them back in seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part people get wrong is when that plan expires.&lt;/strong&gt; The rollback is valid right up until the new database takes its first write. After that it is void, and restarting the old host actively makes things worse, because now writes are split across two databases and you have to reconcile them by hand. From the first write onward you roll forward and you fix problems where the traffic already is.&lt;/p&gt;

&lt;p&gt;Knowing exactly where that line sits is what lets you move fast before it and stop hesitating after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrate your OAuth config weeks before your infrastructure
&lt;/h2&gt;

&lt;p&gt;A cutover that touches auth config is a cutover that breaks. We normalized every redirect URI to our own domain well ahead of time, so cutover night touched zero OAuth config.&lt;/p&gt;

&lt;p&gt;Our plan doc's list of which platforms needed this was wrong in both directions. We assumed X, Instagram and TikTok were already on the canonical domain. Only TikTok was, and X is our highest-traffic connect path. Meanwhile Threads, Pinterest and YouTube were not on the list at all. Grep the live environment export, not the plan doc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify OAuth by connecting, not by reading config
&lt;/h2&gt;

&lt;p&gt;A redirect URI that looks right in a dashboard proves nothing. We ran a real connect on all 11 platforms and watched the nonce rows get created and consumed.&lt;/p&gt;

&lt;p&gt;That is how we found a caching bug in our own registration service: it cached a client per instance domain and short-circuited whenever a cached ID and secret existed, without comparing the cached redirect URI to the requested one. It built the authorize URL from the new env var while sending the old client ID. We also found a Facebook scope error that had been quietly broken for three weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only one worker can exist at a time if you use rotating refresh tokens
&lt;/h2&gt;

&lt;p&gt;X and Bluesky issue a new refresh token on every use and kill the old one. Two workers polling the same account means one silently invalidates the other's credentials, and the account starts failing to publish with no obvious cause.&lt;/p&gt;

&lt;p&gt;This is what shapes a parallel-run migration. A second &lt;em&gt;web&lt;/em&gt; service alongside the old one is harmless, because reads do not rotate anything. A second worker is not. We deployed the new worker once, confirmed it booted with a supervisor, dispatcher, three workers and a scheduler enumerating all 27 recurring tasks, then removed the deployment and disconnected the repo so nothing could auto-deploy it back on the next merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove env parity by hashing, not by eyeballing
&lt;/h2&gt;

&lt;p&gt;123 environment variables. Eyeballing that is not verification, and the failure mode is nasty: one wrong byte in a secret produces an auth error days later that looks like something else entirely.&lt;/p&gt;

&lt;p&gt;Our first pass used a dashboard .env export and it lied to us. Three secrets appeared to carry literal double quotes, and we nearly reproduced those quotes byte-for-byte in production. Pulling the same variables from the platform API showed no quotes. They were an export formatting artifact.&lt;/p&gt;

&lt;p&gt;We generated a sha256 prefix, length and whitespace flags for every value and diffed that manifest against the destination's own JSON output. Two values genuinely were byte-sensitive: a multiline PEM key and a secret with a meaningful trailing newline. Exactly the ones a careless copy-paste destroys, and exactly the ones a hash comparison catches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore into an empty schema
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_restore --clean&lt;/code&gt; against a pre-provisioned schema died on dependency-ordered drops, complaining about multiple primary keys on one table. What worked: &lt;code&gt;DROP SCHEMA public CASCADE&lt;/code&gt;, recreate it, then a plain &lt;code&gt;pg_restore&lt;/code&gt; with no &lt;code&gt;--clean&lt;/code&gt;. Verify against row counts captured before the suspend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The db:prepare trap in Rails multi-database setups
&lt;/h2&gt;

&lt;p&gt;When two logical databases live on one physical Postgres instance, &lt;code&gt;db:prepare&lt;/code&gt; creates the first and then treats the second as already existing, so its schema never loads. The deploy goes green, then seeding crashes the first time it enqueues a job because no solid_queue tables exist.&lt;/p&gt;

&lt;p&gt;Three traps sit inside the fix. Chaining &lt;code&gt;db:prepare db:schema:load:queue&lt;/code&gt; silently no-ops, because rake invokes a task once per run and the prerequisite already ran. A production &lt;code&gt;schema:load&lt;/code&gt; refuses to run without an explicit environment-check override. And if your host's redeploy replays a frozen config snapshot, a pre-deploy-based fix never executes at all.&lt;/p&gt;

&lt;p&gt;What worked was SSHing into the running container and running the schema load directly, verifying with an actual &lt;code&gt;SolidQueue::Job.count&lt;/code&gt; query returning 0 rather than an exception. We reverted the pre-deploy command back to plain &lt;code&gt;db:prepare&lt;/code&gt; afterward, because a schema load is destructive and must never run against restored production data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threads do not survive SolidQueue's fork
&lt;/h2&gt;

&lt;p&gt;We ship logs with a background flusher thread started once in an initializer. That initializer runs in the SolidQueue supervisor process. SolidQueue then forks its worker and dispatcher children, and threads do not survive fork. Each child inherited a handle pointing at a dead thread, the &lt;code&gt;||=&lt;/code&gt; never respawned it, and nothing ever flushed.&lt;/p&gt;

&lt;p&gt;Logs still teed to stdout, so the host's log view looked completely normal. The only flush that ever fired was the inherited &lt;code&gt;at_exit&lt;/code&gt; on process exit, dumping hundreds of buffered lines at once with ingestion-time timestamps. Signature: bursts of identical-timestamp events at restart moments, silence in between. Ours had been dead for days.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration is not done when traffic moves
&lt;/h2&gt;

&lt;p&gt;Point-in-time recovery sat above our plan tier, so we built a nightly &lt;code&gt;pg_dump&lt;/code&gt; to a private object-storage bucket. Then we actually restored from it into a scratch database on the same instance and compared row counts against live before trusting it.&lt;/p&gt;

&lt;p&gt;One packaging trap: Debian's stock &lt;code&gt;postgresql-client&lt;/code&gt; is pg_dump 15, and 15 hard-refuses to dump a Postgres 18 server. You need &lt;code&gt;postgresql-client-18&lt;/code&gt; from PGDG, and you must derive the Debian codename dynamically. Our Ruby base image moved to trixie underneath us, so a hardcoded codename would have broken the build.&lt;/p&gt;

&lt;p&gt;We did not delete the old host until a clean week of monitoring said the new one was holding.&lt;/p&gt;




&lt;p&gt;Full writeup with the cutover sequence and the rest of the detail: &lt;a href="https://xreplyai.com/blog/render-to-railway-migration-guide" rel="noopener noreferrer"&gt;https://xreplyai.com/blog/render-to-railway-migration-guide&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>Our test stubbed the bug, so the bug passed</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Tue, 04 Aug 2026 18:14:59 +0000</pubDate>
      <link>https://dev.to/johnbuilds/our-test-stubbed-the-bug-so-the-bug-passed-1hf9</link>
      <guid>https://dev.to/johnbuilds/our-test-stubbed-the-bug-so-the-bug-passed-1hf9</guid>
      <description>&lt;p&gt;A vendor API we integrate with sends a field called &lt;code&gt;publicaly_available_post_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is not a typo I introduced writing this post. That is the field name. It ships in their docs and it ships on the wire, missing the "l", and it has presumably been that way since whoever wrote the endpoint typed it.&lt;/p&gt;

&lt;p&gt;Our code read &lt;code&gt;publicly_available_post_id&lt;/code&gt;. Correct English. A field the vendor has never once sent.&lt;/p&gt;

&lt;p&gt;That should have been caught immediately, and here is why it wasn't: the test stubbed the correctly-spelled field too.&lt;/p&gt;

&lt;p&gt;I had written something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;stub_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"PUBLISH_COMPLETE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;publicly_available_post_id: &lt;/span&gt;&lt;span class="s2"&gt;"7391..."&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then asserted that our reader picked up the ID. It did. Green. The test passed for months.&lt;/p&gt;

&lt;p&gt;What that test proved is that our reader agrees with our stub. Both were written by the same person on the same afternoon out of the same wrong assumption, so of course they agreed. The vendor was never in the room.&lt;/p&gt;

&lt;p&gt;The failure surfaced somewhere else entirely. There was a fallback: if the post ID is missing, store the temporary upload handle instead. So the first post that ever completed publishing stored an upload handle where a permanent post ID belongs. Nothing errored. The record looked fine. The breakage appeared weeks later in a different subsystem, where the metrics fetcher asked for stats on an ID the vendor had no idea about and got back an error message about integers.&lt;/p&gt;

&lt;p&gt;Three things I took from it.&lt;/p&gt;

&lt;p&gt;Copy field names, never retype them. Every character of a vendor's wire format should arrive in your codebase via paste, from their reference response. The moment you type it from memory you have a second source of truth, and your test will loyally defend it.&lt;/p&gt;

&lt;p&gt;A stub you wrote is not evidence about the vendor. It is evidence about you. If a test's fixture and the code under test share an author and an assumption, the test can only catch typos in the implementation, not errors in the belief. Somewhere in the suite there should be one real captured response from the actual API, warts and misspellings preserved.&lt;/p&gt;

&lt;p&gt;Watch fallbacks that make an absent value look present. The &lt;code&gt;|| upload_handle&lt;/code&gt; fallback turned "we have no ID" into "here is an ID", which is how a parsing bug got laundered into a data integrity bug and shipped off for another team's code to discover.&lt;/p&gt;

&lt;p&gt;The fix was three lines. Read the misspelled field first, keep the correct spelling as a fallback in case they ever fix it, and re-stub the test from a raw response captured off the wire.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>api</category>
      <category>webdev</category>
      <category>lessonslearned</category>
    </item>
    <item>
      <title>My A/B test wasn't broken. My counter was.</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Mon, 03 Aug 2026 18:05:21 +0000</pubDate>
      <link>https://dev.to/johnbuilds/my-ab-test-wasnt-broken-my-counter-was-1a14</link>
      <guid>https://dev.to/johnbuilds/my-ab-test-wasnt-broken-my-counter-was-1a14</guid>
      <description>&lt;p&gt;I ran a 50/50 homepage split for a couple of weeks. The arm counts came back 178 to 57.&lt;/p&gt;

&lt;p&gt;That is not 50/50. So I did what you'd do: I went and stared at the randomizer. Read the middleware. Read the cookie logic. Wrote a script to call the assignment function ten thousand times and count the buckets. It came back 4,981 / 5,019, which is exactly what &lt;code&gt;Math.random() &amp;lt; 0.5&lt;/code&gt; is supposed to do, and left me with a working randomizer and a number that said otherwise.&lt;/p&gt;

&lt;p&gt;The randomizer was fine. The counter was lying.&lt;/p&gt;

&lt;p&gt;Here's what happened. I had one function doing two jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setVariant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;set&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ab_variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;arm&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;          &lt;span class="c1"&gt;// must run on every page load&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ab_variant_assigned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{...});&lt;/span&gt; &lt;span class="c1"&gt;// should run ONCE per visitor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two calls look like they belong together. They do not. The &lt;code&gt;set&lt;/code&gt; call attaches the arm as a sticky dimension to every event that follows it, so it has to repeat on every single page load or your later events lose the label. The &lt;code&gt;event&lt;/code&gt; call records an exposure: one human entering the experiment. It should fire once.&lt;/p&gt;

&lt;p&gt;I called that function from the root layout. Every page load, sitewide, fired both. So my "exposure" count was really a pageview count.&lt;/p&gt;

&lt;p&gt;And then the part that actually fooled me: the two arms rendered through different layout trees, so they accumulated pageviews at different rates. If both arms had over-counted evenly I'd have seen 178 vs 174, shrugged, and moved on with a wrong-but-harmless denominator. Instead the uneven inflation produced a lopsided ratio, which is exactly the shape a broken randomizer makes. The bug disguised itself as a different bug.&lt;/p&gt;

&lt;p&gt;The fix was to split the two operations and guard only the exposure one, keyed by arm value so a re-bucketed visitor counts once per arm instead of vanishing. If the guard's storage throws (Safari private mode does this), it returns "yes, count it". Over-counting one arm is recoverable, silently dropping it is not.&lt;/p&gt;

&lt;p&gt;Three things I took from it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;set&lt;/code&gt; and &lt;code&gt;event&lt;/code&gt; have opposite idempotency requirements.&lt;/strong&gt; One must repeat, one must not. If they share a function, one of them is wrong. Mine had been wrong for weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mounting anything in a root layout makes it a per-pageview thing.&lt;/strong&gt; That's the whole job of a root layout. Any one-shot event you put there (experiment assignment, activation, funnel entry) quietly becomes a tally of page loads instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ratio that contradicts your randomizer is not evidence about your randomizer.&lt;/strong&gt; It's evidence that one of the two is wrong, and the counter is the one nobody tests. I spent a day proving the coin was fair. Nobody had checked whether I was writing the results down correctly.&lt;/p&gt;

&lt;p&gt;The conversion counts were fine, incidentally. Only the denominators were inflated, which means every conversion rate I'd computed was garbage while every number feeding it was correct. That is a fun way to lose two weeks of a test.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The 2.5-hour week: a posting system for developers who hate posting</title>
      <dc:creator>John Builds</dc:creator>
      <pubDate>Wed, 29 Jul 2026 15:25:38 +0000</pubDate>
      <link>https://dev.to/johnbuilds/the-25-hour-week-a-posting-system-for-developers-who-hate-posting-489e</link>
      <guid>https://dev.to/johnbuilds/the-25-hour-week-a-posting-system-for-developers-who-hate-posting-489e</guid>
      <description>&lt;p&gt;Most developers building a side project know they should be visible somewhere (X, LinkedIn, wherever their users are), and most quietly skip it, because context-switching out of code into "content" is miserable.&lt;/p&gt;

&lt;p&gt;I fought this for months while building my SaaS. What stuck was making posting look like a build pipeline instead of a creative act:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch on Sunday (90 min).&lt;/strong&gt; Write the whole week's posts in one sitting. Ideas come from the week's commits, bugs, and user conversations, so the material is already there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Publish on a queue.&lt;/strong&gt; Fixed slots, Tue/Thu/Sat. No decisions during the week.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reply 10 minutes a day.&lt;/strong&gt; Small comments in your niche compound faster than posts do.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Recycle monthly.&lt;/strong&gt; Your best post from last month is new to most of this month's audience.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mental shift: treat consistency as infrastructure. Cron beats willpower.&lt;/p&gt;

&lt;p&gt;I ended up building this into my own product, XreplyAI, which plans and publishes across 15 platforms from one calendar: &lt;a href="https://xreplyai.com?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=edusales-2026-07-29" rel="noopener noreferrer"&gt;https://xreplyai.com?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=edusales-2026-07-29&lt;/a&gt;&lt;/p&gt;

</description>
      <category>solofounder</category>
      <category>productivity</category>
      <category>indiehacker</category>
      <category>socialmedia</category>
    </item>
  </channel>
</rss>
