← Back to Blog
DevOps2026-05-10Β·74 min read

How I fixed a Bluesky image upload race against Cloudflare Pages deploy lag

By MORINAGA

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:

  1. Implicit CDN Assumptions: Teams treat git push as a synchronous deployment trigger, ignoring the asynchronous nature of edge network propagation.
  2. Silent Error Handling: HTTP fetch wrappers often catch network failures and return null or empty responses, allowing the pipeline to continue without blocking.
  3. Platform API Design Divergence: Platforms like Dev.to or Hashnode accept cover_image as 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

  1. 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.
  2. 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.
  3. 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.
  4. Buffer Safety: Node.js Buffer instances are Uint8Array subclasses backed by a shared ArrayBuffer pool. Small allocations often share the same underlying memory block. Extracting bytes via slice(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 ArrayBuffer safely using byteOffset and byteLength to 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

  1. Initialize the resolver: Instantiate ContentAssetResolver with process.env.REPO_ROOT and your asset directory path.
  2. Wire the fallback chain: Call resolve(contentPath, remoteUrl) before any platform-specific upload routines.
  3. Validate payload size: Check payload.bytes.byteLength against MAX_BLOB_BYTES before invoking uploadBlob.
  4. Handle degradation: If resolution returns null, compose a text-only post and log the strategy used for audit purposes.
  5. 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.