This article was originally published on Jo4 Blog.
In our previous post, we covered the producer-consumer problem for blog scheduling. But we glossed over the crossposting part.
"Just POST to the dev.to API," we said. "How hard could it be?"
Narrator: It was hard.
The Setup
We have a Node.js script that runs daily via GitHub Actions:
// Simplified flow
1. Find all markdown posts with publishAfter <= today
2. Check if they exist on dev.to already
3. If not, create them
4. Send Slack notification
Sounds straightforward. Here are the edge cases that broke it.
Edge Case 1: How Do You Know If a Post Already Exists?
The Problem
We can't just check our local database—we don't have one. The blog is a static site. So how do we avoid posting duplicates?
Naive approach: Keep a .crossposted.json file locally.
Why it fails: Someone manually posts to dev.to. Someone deletes the JSON file. Someone runs the script from a different machine. Duplicates everywhere.
The Fix: dev.to Is the Source of Truth
async fetchDevtoArticles() {
const response = await fetch(`${DEVTO_API_URL}/me/published?per_page=100`, {
headers: { 'api-key': this.apiKey },
});
const articles = await response.json();
// Store by canonical_url for O(1) lookup
for (const article of articles) {
if (article.canonical_url) {
this.devtoArticles.set(article.canonical_url, {
id: article.id,
url: article.url,
title: article.title,
});
}
}
}
Before creating anything, we fetch ALL our existing dev.to articles. The canonical_url field is unique—it's the original source URL. If our canonical URL already exists, skip.
Bonus: dev.to returns a 422 error with "canonical" in the message if you try to create a duplicate. We catch that too:
if (response.status === 422 && errorText.toLowerCase().includes('canonical')) {
return { duplicate: true, title: frontmatter.title };
}
Belt and suspenders.
Edge Case 2: The 60-Day Time Bomb
The Problem
Our script only looks back 60 days on dev.to (performance optimization—we don't need articles from 2 years ago). But what happens to a post with publishAfter: "2025-01-01" that we never crossposted?
Scenario:
- January: Write a post, set
publishAfter: "2025-01-15" - January 15: Script runs, posts to dev.to ✅
- March 20: 65 days later, the script's dev.to lookback window no longer includes this article
- Some bug causes the post to be re-processed
- Duplicate post on dev.to ❌
The Fix: Auto-Update Stale Dates
If a post has publishAfter older than 60 days, we automatically update it to today:
if (this.isOlderThanDevtoMaxDays(publishAfter)) {
console.log(`[publish-after] "${title}" has old publishAfter (${publishAfter}), updating to ${today}`);
return { shouldProcess: true, needsDateUpdate: true, newDate: today };
}
And here's the edge case's edge case—we need to commit this change to git:
// After processing all posts
if (this.filesToCommit.length > 0 && !this.dryRun) {
commitChanges(this.filesToCommit, 'chore: auto-update old publishAfter dates to today');
}
function commitChanges(files, message) {
for (const file of files) {
execSync(`git add "${file}"`, { stdio: 'pipe' });
}
execSync(`git commit -m "${message}"`, { stdio: 'pipe' });
}
Why commit? Because if the script runs again before you pull, it would try to update the same posts again. The commit ensures the updated dates persist.
Slack notification: "⚠️ publishAfter updated to today for "My Post" - please pull latest"
Edge Case 3: Accidental Content Overwrites
The Problem
You crosspost a blog post. A week later, you fix a typo locally. The script runs. Does it update dev.to?
If yes: What if you intentionally made dev.to-specific edits? Gone.
If no: How do you push updates when you actually want them?
The Fix: Explicit Update Intent
Updates only happen when you set updatedAt in frontmatter:
---
title: "My Post"
publishAfter: "2026-02-15"
updatedAt: "2026-02-20" # <-- This triggers the update
---
if (existingArticle) {
if (frontmatter.updatedAt) {
// Explicit intent to update - proceed
result = await this.updateArticle(existingArticle.id, frontmatter, body);
} else {
// No updatedAt = skip (don't accidentally overwrite)
console.log(`[exists] ${frontmatter.title}`);
continue;
}
}
No updatedAt? No update. Simple opt-in.
Edge Case 4: dev.to Rate Limiting
The Problem
dev.to allows 10 requests per 30 seconds. Try to crosspost 15 articles at once and you'll hit 429s.
The Fix: Delay + Retry with Backoff
// After each successful post
await new Promise(resolve => setTimeout(resolve, 3500)); // 3.5s delay
// On rate limit (429)
if (response.status === 429 && retryCount < CONFIG.maxRetries) {
const retryAfter = parseInt(response.headers.get('retry-after'), 10) || 60;
console.log(`[rate-limited] Waiting ${retryAfter}s before retry...`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.createArticle(frontmatter, body, retryCount + 1);
}
3.5 seconds between posts keeps us under the limit. If we do hit a 429, respect the retry-after header and try again.
Edge Case 5: Partial Failures
The Problem
You have 5 posts to crosspost. Posts 1 and 2 succeed. Post 3 fails (network error). What happens to posts 4 and 5?
The Fix: Continue on Failure + Report All
for (const file of files) {
try {
result = await this.createArticle(frontmatter, body);
results.push({ action: 'created', title: frontmatter.title, url: result.url });
await notifySlack(`Crossposted to dev.to: ${title} → ${result.url}`);
} catch (error) {
console.error(`[failed] ${frontmatter.title}: ${error.message}`);
results.push({ action: 'failed', title: frontmatter.title, error: error.message });
await notifySlack(`Failed to crosspost "${title}": ${error.message}`, true);
// Continue to next post - don't abort
}
}
// Exit with error code if any failures
if (failed > 0) {
process.exit(1);
}
Every post gets attempted. Every result gets recorded. Every failure gets Slacked. The exit code tells CI whether to retry.
The Complete Slack Notification System
Here's every scenario that triggers a notification:
| Event | Emoji | Message |
|---|---|---|
| New crosspost | ✅ | Crossposted to dev.to: {title} → {url} |
| Updated post | ✅ | Updated on dev.to: {title} → {url} |
| Date auto-fixed | ⚠️ | publishAfter updated to today for "{title}" - please pull latest |
| Duplicate found | ⚠️ | Duplicate on dev.to for "{title}" - already exists, skipping |
| Failure | ⚠️ | Failed to crosspost "{title}": {error} |
Slack Integration
One environment variable:
export SLACK_JO4_BLOGS_WH="https://hooks.slack.com/services/T00/B00/XXX"
That's it. The script handles the rest:
async function notifySlack(message, isWarning = false) {
if (!CONFIG.slackWebhook) {
console.log(`[slack-skip] ${message}`);
return;
}
const emoji = isWarning ? '⚠️' : '✅';
const text = `${emoji} *Jo4 Blog*: ${message}`;
await fetch(CONFIG.slackWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
}
No Slack webhook configured? It logs to console instead. Graceful degradation.
Configuration Knobs
const CONFIG = {
localMaxDays: parseInt(process.env.LOCAL_MAX_DAYS, 10) || 30,
devtoMaxDays: parseInt(process.env.DEVTO_MAX_DAYS, 10) || 60,
maxRetries: 3,
slackWebhook: process.env.SLACK_JO4_BLOGS_WH,
};
| Variable | Default | Purpose |
|---|---|---|
LOCAL_MAX_DAYS |
30 | Only process posts from last X days (unless publishAfter is set) |
DEVTO_MAX_DAYS |
60 | How far back to check dev.to for existing articles |
SLACK_JO4_BLOGS_WH |
- | Slack webhook URL |
DEVTO_API_KEY |
- | Your dev.to API key (required) |
The Full Algorithm
1. Fetch all our articles from dev.to (last 60 days)
→ Store by canonical_url for O(1) lookup
2. For each local markdown file:
a. Skip if draft or crosspost: false
b. Skip if publishAfter > today (scheduled for future)
c. If publishAfter is older than 60 days:
→ Update publishAfter to today
→ Queue file for git commit
→ Slack warning
d. Check if canonical_url exists on dev.to:
→ If yes AND updatedAt is set: UPDATE
→ If yes AND no updatedAt: SKIP
→ If no: CREATE
e. Wait 3.5 seconds (rate limiting)
f. On 429: retry with backoff
3. Commit any auto-updated files to git
4. Report summary + exit code
Lessons Learned
- Use the destination as source of truth - Don't maintain local state for external systems
-
Explicit > implicit - Updates require
updatedAtflag, no accidental overwrites - Edge cases have edge cases - Old dates need auto-fixing, auto-fixes need git commits
- Fail gracefully - Continue on error, report everything, use exit codes for CI
- Integrate Slack early - One webhook URL, five notification scenarios, zero config UI
What edge cases have you hit with crossposting? Every automation has that one bug that only shows up at 3 AM.
Building jo4.io - URL shortener with analytics. Our blog auto-crossposts to dev.to using exactly this system.
Top comments (0)