This article was originally published on Jo4 Blog.
I woke up to 47 Slack notifications. All from the same bot. All saying the same thing: "Duplicate on dev.to for [article] - already exists, skipping."
Every single CI run — push triggers, daily crons, all of them — was firing the same warnings for the same posts. Over and over. For weeks.
TL;DR: Our crosspost script had a 60-day sliding window for fetching dev.to articles. As posts aged out of that window, the script forgot they existed, tried to recreate them, got 422 duplicate errors, and Slacked us about it. Every. Single. Run. The fix was to remove the time window entirely and fetch all published articles with pagination.
The Architecture That Worked (For a While)
Our dev.to crosspost script had two time-based filters:
const CONFIG = {
localMaxDays: parseInt(process.env.LOCAL_MAX_DAYS, 10) || 30,
devtoMaxDays: parseInt(process.env.DEVTO_MAX_DAYS, 10) || 60,
};
The idea was simple:
-
LOCAL_MAX_DAYS(30 days): Only process local posts from the last 30 days. Don't waste time on old articles. -
DEVTO_MAX_DAYS(60 days): Only fetch dev.to articles from the last 60 days. Don't paginate through years of content.
The 60-day window was intentionally wider than 30 days. A post from day 29 would still be found in the dev.to lookback window. Plenty of margin.
This worked perfectly for months.
The Invariant That Broke
We had a feature called publishAfter — a frontmatter field that lets you schedule posts for future publication. When a post's publishAfter date arrives, the script processes it regardless of LOCAL_MAX_DAYS.
See the problem? publishAfter bypasses the 30-day local filter. A post from 90 days ago with publishAfter set would still get processed. But the dev.to lookback only goes back 60 days.
Here's what happened:
Day 1: Post published. publishAfter = "2026-01-15"
Day 1: Script runs → creates article on dev.to
Day 60: Article falls out of DEVTO_MAX_DAYS window
Day 61: Script can't find it in dev.to cache
Day 61: publishAfter bypasses LOCAL_MAX_DAYS → post is processed
Day 61: Script tries to CREATE → dev.to returns 422 (duplicate)
Day 61: Slack notification: "Duplicate on dev.to, skipping"
Day 62: Same thing
Day 63: Same thing
Day 400: Still the same thing
The 422 was handled gracefully — we caught it, logged it, sent a Slack warning, and moved on. The script didn't crash. It just spammed us relentlessly.
The Feedback Loop That Made It Worse
But wait, it gets better.
We had another "helpful" feature: when the script detected a post with a stale publishAfter date (older than DEVTO_MAX_DAYS), it would auto-update the date to today and commit the change to git.
if (this.isOlderThanDevtoMaxDays(publishAfter)) {
// Update publishAfter to today
return { shouldProcess: true, needsDateUpdate: true, newDate: today };
}
Here's the loop this created:
- Old post has
publishAfter: "2026-01-15" - Script detects it's older than 60 days
- Script updates it to
publishAfter: "2026-06-15"(today) - Script commits the change to git
- Git push triggers CI
- CI runs the script again
- The post now has today's date, so it gets processed
- Script can't find it on dev.to (still outside the 60-day window of the original publish date on dev.to)
- Script tries to CREATE, gets 422, sends Slack warning
- Repeat from step 2 next time the date gets stale again
The auto-update was supposed to prevent this exact problem. Instead, it created a commit-push-CI loop that amplified it.
Debugging: Following the Notifications Upstream
The Slack messages were the symptom. I started there:
[warn] Duplicate on dev.to for "Getting Started with Jo4" - already exists, skipping
[warn] Duplicate on dev.to for "Jo4 Origin Story" - already exists, skipping
[warn] Duplicate on dev.to for "Referral Tracking for Indie Hackers" - already exists, skipping
All old posts. All definitely already on dev.to. So why was the script trying to create them?
I checked the dev.to article cache. These posts weren't in it. Because they were published more than 60 days ago, and DEVTO_MAX_DAYS filtered them out.
Then I checked why the script was processing them at all. They were older than LOCAL_MAX_DAYS (30 days). Shouldn't they be skipped? No — publishAfter was set, and the publishAfter logic bypassed the LOCAL_MAX_DAYS check.
Two time windows. Two bypass mechanisms. One broken invariant.
The Fix: Kill the Time Window
The root cause wasn't the publishAfter bypass or the auto-update commit loop. Those were symptoms. The root cause was DEVTO_MAX_DAYS — a premature optimization that created a blind spot.
// Before: only fetch last 60 days
async fetchDevtoArticles() {
const response = await fetch(
`${DEVTO_API_URL}/me/published?per_page=100`,
{ headers: { 'api-key': this.apiKey } }
);
const articles = await response.json();
// Filter to last DEVTO_MAX_DAYS...
}
// After: fetch ALL articles with pagination
async fetchDevtoArticles() {
let page = 1;
let articles;
do {
const response = await fetch(
`${DEVTO_API_URL}/me/published?per_page=100&page=${page}`,
{ headers: { 'api-key': this.apiKey } }
);
if (!response.ok) {
throw new Error(`dev.to API error: ${response.status}`);
}
articles = await response.json();
for (const article of articles) {
if (article.canonical_url) {
this.devtoArticles.set(article.canonical_url, {
id: article.id,
url: article.url,
title: "article.title,"
edited_at: article.edited_at,
});
}
}
page++;
} while (articles.length === 100);
}
Three changes:
Removed
DEVTO_MAX_DAYSentirely. Fetch all published articles. With pagination, this is a few extra API calls at most. We have ~50 articles — one page.Removed the
publishAfterauto-update logic. No more auto-committing date changes. No more commit-push-CI loops. If a post is old, it's old. That's fine.Added fail-fast on API errors. The old code silently returned partial data if a pagination request failed. Now it throws. Partial data is worse than no data — it creates the same blind spot we just fixed.
// Before: silent return with partial data
if (!response.ok) {
console.error(`API error: ${response.status}`);
return; // script continues with incomplete cache
}
// After: fail-fast
if (!response.ok) {
throw new Error(`dev.to API error: ${response.status}`);
}
Why Not Just Increase DEVTO_MAX_DAYS?
Tempting. Set it to 365 days and call it a day. But that's patching the symptom:
- It delays the problem by a year instead of fixing it.
- The
publishAfterbypass still violates the invariant. - The auto-update commit loop still exists.
- You're betting that no post will ever be older than your magic number.
Fetching all articles is the correct solution. The performance cost is negligible — dev.to's API returns 100 articles per page, and pagination adds maybe 200ms per extra page. We'd need 1,000+ articles before this matters.
The Lesson: Time Windows Need Invariants
Two time windows that are "designed to align" will eventually drift. Especially when other features bypass one window but not the other.
If you have a cache with a time-based eviction window, ask yourself:
- Can anything bypass the filter that feeds into this cache?
- What happens when an item falls out of the cache but is still referenced?
- Is partial data from this cache worse than no data?
For us, the answers were: yes, Slack spam, and absolutely.
The 60-day window saved us approximately zero performance. It cost us weeks of duplicate alerts and a debugging session that touched every part of the pipeline. Premature optimization, meet production consequences.
Have you been burned by time-based caching creating blind spots? I'd love to hear your war story.
Building jo4.io — a URL shortener with analytics, bio pages, and an affiliate marketplace. Our blog crosspost pipeline no longer floods Slack.
Top comments (0)