How I fixed a Bluesky image upload race against Cloudflare Pages deploy lag
Synchronous Blob Uploads in CI: Solving CDN Propagation Race Conditions
Current Situation Analysis
Modern content distribution pipelines frequently encounter a structural mismatch: asynchronous infrastructure propagation versus synchronous external API requirements. When a CI/CD workflow generates assets, commits them to a repository, and immediately triggers a deployment, developers often assume the resulting CDN endpoints are instantly reachable. This assumption breaks down when external platforms demand synchronous binary uploads rather than URL references.
The AT Protocol, which powers Bluesky, enforces this constraint strictly. The com.atproto.repo.uploadBlob endpoint requires raw byte streams. It does not accept remote URLs for media attachments. When a pipeline pushes newly generated images to a static host like Cloudflare Pages, the platform initiates a build and propagation cycle that typically spans 2β4 minutes. If the CI runner attempts to fetch the image from its public URL immediately after the git push completes, the request hits a 404. Because many HTTP clients or wrapper libraries silently swallow network errors or default to fallback behavior, the pipeline reports success while the external post publishes without its intended media attachment.
This failure mode is frequently overlooked for three reasons:
- Implicit CDN Assumptions: Teams treat
git pushas a synchronous deployment trigger, ignoring the asynchronous nature of edge network propagation. - Silent Error Handling: HTTP fetch wrappers often catch network failures and return
nullor empty responses, allowing the pipeline to continue without blocking. - Platform API Design Divergence: Platforms like Dev.to or Hashnode accept
cover_imageas a URL and perform asynchronous background fetching, masking propagation delays. AT Protocol's synchronous blob requirement exposes the timing gap immediately.
The result is degraded content distribution: posts go live, but engagement metrics suffer due to missing thumbnails. The solution requires decoupling asset availability from network propagation by prioritizing local filesystem resolution within the CI environment.
WOW Moment: Key Findings
The core insight is that asset resolution strategy dictates pipeline reliability. By comparing three common approaches, the performance and reliability trade-offs become quantifiable.
| Approach | Latency | Reliability | Implementation Complexity | Infrastructure Dependency |
|---|---|---|---|---|
| Remote URL Fetch | 2β4 min (fails) | Low (404s) | Low | CDN propagation |
| Deploy Status Polling | 2β4 min (waits) | High | Medium | API tokens, rate limits |
| Local-First Fallback | <100 ms | High | Low | Filesystem access |
Why this matters: The local-first fallback chain eliminates network latency entirely during the critical upload window. It guarantees asset availability without blocking the CI runner, requiring zero additional API credentials, and maintaining compatibility with standalone local execution. This pattern transforms a race condition into a deterministic, sub-millisecond operation.
Core Solution
The architecture centers on a resilient asset resolver that evaluates availability in a strict priority order: local filesystem β remote network β graceful degradation. This approach aligns with CI/CD best practices by minimizing external dependencies during execution.
Step 1: Construct the Asset Resolver
Instead of scattering resolution logic across multiple scripts, encapsulate it in a dedicated module. The resolver accepts a content file path, derives the expected asset location, and attempts local read operations before falling back to network requests.
import { readFile } from "node:fs/promises";
import { join, dirname, basename } from "node:path";
import { fetch } from "undici";
type AssetPayload = {
bytes: ArrayBuffer;
contentType: string;
};
type ResolutionStrategy = "local" | "remote" | "none";
export class ContentAssetResolver {
private readonly repoRoot: string;
private readonly assetDirectory: string;
constructor(repoRoot: string, assetDirectory: string) {
this.repoRoot = repoRoot;
this.assetDirectory = assetDirectory;
}
async resolve(contentPath: string, remoteUrl?: string): Promise<{ payload: AssetPayload | null; strategy: ResolutionStrategy }> {
const slug = basename(contentPath, ".md");
const localPath = join(this.repoRoot, this.assetDirectory, `${slug}.png`);
// Priority 1: Local filesystem
try {
const rawBuffer = await readFile(localPath);
const safeBytes = this.extractSafeArrayBuffer(rawBuffer);
return {
payload: { bytes: safeBytes, contentType: "image/png" },
strategy: "local",
};
} catch {
// Fallback to remote if local fails
}
// Priority 2: Remote network
if (remoteUrl) {
try {
const response = await fetch(remoteUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
return {
payload: { bytes: arrayBuffer, contentType: response.headers.get("content-type") || "image/png" },
strategy: "remote",
};
} catch {
// Fallback to none
}
}
return { payload: null, strategy: "none" };
}
private extractSafeArrayBuffer(nodeBuffer: Buffer): ArrayBuffer {
// Node.js uses pooled buffers for small allocations.
// Directly passing .buffer risks uploading adjacent memory.
const offset = nodeBuffer.byteOffset;
const length = nodeBuffer.byteLength;
return nodeBuffer.buffer.slice(offset, offset + length);
}
}
Step 2: Integrate with AT Protocol Upload
The resolver output feeds directly into the blob upload routine. The AT Protocol enforces a hard binary limit near 1 MB. Exceeding this threshold triggers silent failures or explicit rejections depending on the relay implementation. A defensive size guard prevents wasted network calls and pipeline noise.
import { AtpAgent } from "@atproto/api";
const MAX_BLOB_BYTES = 950_000; // 950KB safety margin below 1MB limit
export async function publishArticleWithMedia(
agent: AtpAgent,
contentPath: string,
remoteAssetUrl: string | undefined,
resolver: ContentAssetResolver
) {
const { payload, strategy } = await resolver.resolve(contentPath, remoteAssetUrl);
if (!payload) {
console.warn(`[AssetResolver] No media available for ${contentPath}. Publishing text-only.`);
return await agent.post({ text: "Article published without thumbnail." });
}
if (payload.bytes.byteLength > MAX_BLOB_BYTES) {
console.warn(`[AssetResolver] Asset exceeds ${MAX_BLOB_BYTES}B limit (${payload.bytes.byteLength}B). Skipping upload.`);
return await agent.post({ text: "Article published without thumbnail." });
}
console.log(`[AssetResolver] Uploading media via ${strategy} strategy...`);
const blobResponse = await agent.uploadBlob(payload.bytes, {
encoding: payload.contentType,
});
// Compose post with blob reference
return await agent.post({
text: "New article is live.",
embed: {
$type: "app.bsky.embed.external",
external: {
uri: "https://example.com/article",
title: "Article Title",
thumb: blobResponse.data.blob,
},
},
});
}
Architecture Decisions & Rationale
- Local-First Resolution: CI runners retain the exact filesystem state from the build step. Reading from disk takes <100ms, eliminates network variability, and guarantees the asset exists exactly as generated.
- Remote Fallback: Standalone execution (e.g., local development or manual re-publishing) lacks the CI-generated files. The remote URL fallback preserves portability without coupling the publish script to a specific runner environment.
- Graceful Degradation: The pipeline never blocks on media availability. Text-only posts ensure content distribution continues even if asset generation fails. This aligns with idempotent CI design: partial failures should not halt the entire workflow.
- Buffer Safety: Node.js
Bufferinstances areUint8Arraysubclasses backed by a sharedArrayBufferpool. Small allocations often share the same underlying memory block. Extracting bytes viaslice(byteOffset, byteOffset + byteLength)guarantees only the target file's data is transmitted, preventing memory corruption or oversized payloads.
Pitfall Guide
1. Assuming Immediate CDN Readiness
Explanation: Treating git push as a synchronous deployment trigger ignores edge network propagation delays. Static hosts queue builds, run pipelines, and distribute assets across PoPs.
Fix: Never rely on public URLs during the same CI run that generates the asset. Use local resolution or explicit deploy status polling.
2. Ignoring Node.js Buffer Pooling
Explanation: Passing buffer.buffer directly to network APIs uploads adjacent memory allocations. This causes silent data corruption or payload size violations.
Fix: Always slice using byteOffset and byteLength. Validate payload size before transmission.
3. Hardcoding Relative Paths Without Context
Explanation: Chaining dirname() calls assumes a fixed directory depth. Repository restructuring or monorepo adjustments break the resolution logic silently.
Fix: Inject the repository root via environment variables (GITHUB_WORKSPACE or CI_PROJECT_DIR). Validate path existence before read attempts.
4. Treating Silent API Failures as Successes
Explanation: HTTP clients often catch network errors and return null. Pipelines continue execution, publishing content without media.
Fix: Implement explicit status checks. Log resolution strategy and payload size. Fail fast or degrade gracefully with clear audit trails.
5. Blocking CI on External Deploy Webhooks
Explanation: Polling deployment status APIs introduces rate limit risks, requires additional credentials, and extends pipeline duration unnecessarily. Fix: Decouple asset availability from deployment timing. Local resolution removes the need for external status checks entirely.
6. Overlooking Binary Size Limits
Explanation: AT Protocol relays enforce strict blob size thresholds. Exceeding limits triggers silent rejections or explicit errors that halt uploads. Fix: Implement a defensive size guard (e.g., 950KB). Compress or resize assets before upload if they approach the threshold.
7. Mixing Synchronous and Asynchronous Resolution
Explanation: Attempting to fetch remote assets while simultaneously reading local files creates race conditions and unpredictable fallback behavior. Fix: Enforce strict priority ordering. Resolve locally first. Only attempt network requests if local resolution fails.
Production Bundle
Action Checklist
- Inject repository root via CI environment variables instead of chaining
dirname()calls - Implement a three-tier resolution chain: local β remote β graceful degradation
- Validate payload size against platform limits before initiating blob uploads
- Extract
ArrayBuffersafely usingbyteOffsetandbyteLengthto avoid Node.js pool corruption - Log resolution strategy and asset size for CI audit trails
- Configure fallback behavior to publish text-only posts when media resolution fails
- Test pipeline with simulated 404 responses to verify fallback chain integrity
- Monitor AT Protocol relay responses for silent rejection patterns
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| CI/CD pipeline generating assets | Local-First Fallback | Zero network latency, guaranteed availability, no external dependencies | None |
| Manual re-publishing from fresh checkout | Remote URL Fallback | Local files absent; network fetch restores functionality | Network bandwidth, CDN egress |
| High-frequency publishing with strict SLAs | Deploy Status Polling | Guarantees public URL readiness before upload | API rate limits, pipeline duration increase |
| Multi-platform distribution (Bluesky + Dev.to) | Hybrid Resolver | Local for synchronous blob uploads, remote for URL-based APIs | Minimal, shared resolver logic |
Configuration Template
# .github/workflows/publish-content.yml
name: Content Distribution Pipeline
on:
push:
paths:
- "content/articles/**"
- "scripts/generate-assets.py"
env:
REPO_ROOT: ${{ github.workspace }}
ASSET_DIR: "apps/content/public/media/thumbnails"
MAX_BLOB_SIZE: 950000
jobs:
generate-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Generate media assets
run: python scripts/generate-thumbnails.py
- name: Commit generated assets
run: |
git config user.name "ci-bot"
git config user.email "ci@domain.com"
git add ${{ env.ASSET_DIR }}
git commit -m "chore: add generated thumbnails" || echo "No changes to commit"
git push
- name: Publish to distribution platforms
run: node scripts/publish-distribution.js
env:
AT_PROTOCOL_HANDLE: ${{ secrets.AT_HANDLE }}
AT_PROTOCOL_PASSWORD: ${{ secrets.AT_PASSWORD }}
CONTENT_BASE_URL: "https://cdn.example.com/media/thumbnails"
Quick Start Guide
- Initialize the resolver: Instantiate
ContentAssetResolverwithprocess.env.REPO_ROOTand your asset directory path. - Wire the fallback chain: Call
resolve(contentPath, remoteUrl)before any platform-specific upload routines. - Validate payload size: Check
payload.bytes.byteLengthagainstMAX_BLOB_BYTESbefore invokinguploadBlob. - Handle degradation: If resolution returns
null, compose a text-only post and log the strategy used for audit purposes. - Test locally: Run the publish script outside CI with a missing local file to verify remote fallback behavior. Confirm that local files bypass network requests entirely.
This pattern transforms a fragile timing dependency into a deterministic, production-ready asset pipeline. By prioritizing local resolution, enforcing buffer safety, and implementing graceful degradation, you eliminate CDN propagation race conditions while maintaining cross-platform compatibility and CI reliability.
