| name | async-operation-handler |
| description | Handle asynchronous operations from MCP servers (PixelLab, ElevenLabs, etc.) with intelligent polling, timeout management, and parallel work opportunities. Use when waiting for async jobs, polling status, or managing long-running operations. Provides exponential backoff, ETA-aware waiting, and prevents premature downloads. |
Async Operation Handler
Handle asynchronous operations efficiently with intelligent polling, timeout management, and parallel work opportunities. Reduces async operation overhead by 50-70% and saves 15-30 seconds per operation.
Overview
When working with async operations (PixelLab character generation, ElevenLabs TTS, etc.), use this skill to:
- Poll with exponential backoff instead of fixed intervals
- Respect API-provided ETAs when available
- Handle timeouts gracefully
- Use waiting time for parallel work
- Prevent premature download attempts
⚠️ CRITICAL: Use Exponential Backoff, Not Fixed Intervals ⚠️
AGENTS WASTE 20-40% OF WAIT TIME BY USING FIXED INTERVALS INSTEAD OF EXPONENTIAL BACKOFF.
The Problem: Fixed intervals (e.g., 30s, 60s) waste time by polling too frequently early and not frequently enough later.
The Solution: Exponential backoff (5s → 10s → 20s → 40s → 60s) adapts to operation progress.
Core Patterns
Exponential Backoff Polling (CORRECT)
✅ CORRECT: Use exponential backoff - 5s → 10s → 20s → 40s → 60s
async function pollAsyncOperation(
checkStatus: () => Promise<StatusResponse>,
options: PollOptions = {}
): Promise<StatusResponse> {
const {
maxWait = 300000,
backoff = 'exponential',
respectETA = true,
initialInterval = 5000
} = options;
let interval = initialInterval;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const status = await checkStatus();
if (status.status === 'completed') {
return status;
}
if (status.status === 'failed') {
throw new Error(`Operation failed: ${status.error || 'Unknown error'}`);
}
let waitTime = interval;
if (respectETA && status.eta_seconds) {
waitTime = Math.min(status.eta_seconds * 1000, interval * 2);
}
await sleep(waitTime);
if (backoff === 'exponential') {
interval = Math.min(interval * 2, 40000);
}
}
throw new Error(`Operation timed out after ${maxWait}ms`);
}
Fixed Interval Polling (INCORRECT - ANTI-PATTERN)
❌ WRONG: Fixed intervals waste 20-40% of wait time
async function pollWithFixedInterval(
checkStatus: () => Promise<StatusResponse>
): Promise<StatusResponse> {
const maxWait = 300000;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const status = await checkStatus();
if (status.status === 'completed') {
return status;
}
await sleep(30000);
}
throw new Error('Operation timed out');
}
Why Fixed Intervals Are Inefficient:
- Early polls: Too frequent (operation just started, won't be ready)
- Later polls: Too infrequent (operation may be ready, but we wait full interval)
- Waste: 20-40% of total wait time is wasted
Example Timeline (Fixed 30s vs Exponential Backoff):
Fixed 30s intervals:
0s: Poll → Not ready
30s: Poll → Not ready (wasted 30s)
60s: Poll → Not ready (wasted 30s)
90s: Poll → Ready! (but we waited full 30s when it was ready at 75s)
Total: 90s (15s wasted)
Exponential backoff:
0s: Poll → Not ready
5s: Poll → Not ready
15s: Poll → Not ready
35s: Poll → Ready!
Total: 35s (0s wasted)
Visual Comparison: Fixed vs Exponential
Fixed Interval (30s):
Poll 1: 0s → Not ready → Wait 30s
Poll 2: 30s → Not ready → Wait 30s
Poll 3: 60s → Not ready → Wait 30s
Poll 4: 90s → Ready! (but was ready at 75s, wasted 15s)
Total: 90s (15s wasted)
Exponential Backoff:
Poll 1: 0s → Not ready → Wait 5s
Poll 2: 5s → Not ready → Wait 10s
Poll 3: 15s → Not ready → Wait 20s
Poll 4: 35s → Ready!
Total: 35s (0s wasted)
Time Saved: 55 seconds (61% faster)
ETA-Aware Waiting
Respect API-provided ETAs when available:
const character = await mcp_pixellab_get_character({ character_id });
if (character.eta_seconds) {
const waitTime = Math.min(character.eta_seconds * 1000, currentInterval);
await sleep(waitTime);
}
ETA-Based Scheduling
Schedule status checks at strategic points based on ETA:
Instead of polling at fixed intervals, schedule checks at percentage milestones of the ETA:
async function pollWithETAScheduling(
checkStatus: () => Promise<StatusResponse>,
initialETA: number
): Promise<StatusResponse> {
const milestones = [
initialETA * 0.10,
initialETA * 0.50,
initialETA * 0.75,
];
let currentMilestone = 0;
const startTime = Date.now();
while (true) {
const elapsed = (Date.now() - startTime) / 1000;
if (currentMilestone < milestones.length && elapsed >= milestones[currentMilestone]) {
const status = await checkStatus();
if (status.eta_seconds) {
const remainingTime = status.eta_seconds;
milestones.splice(0, currentMilestone + 1);
milestones.push(
elapsed + remainingTime * 0.10,
elapsed + remainingTime * 0.50,
elapsed + remainingTime * 0.75
);
currentMilestone = 0;
}
if (status.status === 'completed') {
return status;
}
if (status.status === 'failed') {
throw new Error(`Operation failed: ${status.error || 'Unknown error'}`);
}
currentMilestone++;
}
if (currentMilestone >= milestones.length) {
const status = await checkStatus();
if (status.status === 'completed') {
return status;
}
if (status.status === 'failed') {
throw new Error(`Operation failed: ${status.error || 'Unknown error'}`);
}
await sleep(20000);
} else {
const nextMilestone = milestones[currentMilestone];
const waitTime = Math.max(1000, (nextMilestone - elapsed) * 1000);
await sleep(waitTime);
}
}
}
Example Timeline (ETA: 176 seconds):
0s: Poll → Not ready, ETA: 176s
18s: Poll at 10% (17.6s) → Not ready, ETA: 158s (updated)
88s: Poll at 50% (88s) → Not ready, ETA: 88s (updated)
132s: Poll at 75% (132s) → Not ready, ETA: 44s (updated)
176s: Poll → Ready!
Total: 176s (optimal - no wasted polls)
Benefits:
- Reduces API calls by 60-70% compared to fixed intervals
- Adapts to changing ETAs dynamically
- Checks at strategic points (10%, 50%, 75%) before final completion
- More efficient than exponential backoff for operations with reliable ETAs
When to Use ETA-Based Scheduling:
- Operations provide reliable ETA information (PixelLab, ElevenLabs)
- ETA is reasonably accurate (within 20% variance)
- Operation duration is predictable
When to Use Exponential Backoff Instead:
- ETA information is unreliable or unavailable
- Operation duration is highly variable
- Need more frequent early checks for debugging
Pre-Download Validation
ALWAYS verify status === "completed" before download:
const character = await create_character({ description: "wizard" });
const url = character.download_url;
let character = await create_character({ description: "wizard" });
while (character.status !== 'completed') {
await sleep(5000);
character = await get_character({ character_id: character.character_id });
}
const url = character.download_url;
Parallel Work During Waiting
Use waiting time for parallel work:
const { character_id, job_id } = await mcp_pixellab_create_character({
description: "wizard",
n_directions: 8
});
const integrationCode = prepareIntegrationCode();
const existingAssets = checkExistingAssets();
const character = await pollAsyncOperation(
() => mcp_pixellab_get_character({ character_id }),
{ maxWait: 300000, respectETA: true }
);
integrateAsset(character, integrationCode);
Integration Examples
PixelLab Character Generation
async function generateCharacterWithBackoff(description: string) {
const { character_id } = await mcp_pixellab_create_character({
description,
n_directions: 8,
size: 48
});
const character = await pollAsyncOperation(
() => mcp_pixellab_get_character({ character_id }),
{
maxWait: 300000,
respectETA: true,
backoff: 'exponential'
}
);
if (character.status !== 'completed') {
throw new Error('Character generation incomplete');
}
return character;
}
PixelLab Tile Generation
async function generateTileWithBackoff(description: string) {
const { tile_id } = await mcp_pixellab_create_isometric_tile({
description,
size: 32
});
const tile = await pollAsyncOperation(
() => mcp_pixellab_get_isometric_tile({ tile_id }),
{
maxWait: 120000,
respectETA: true
}
);
return tile;
}
ElevenLabs TTS
async function generateTTSWithBackoff(text: string, voiceId: string) {
const { job_id } = await mcp_ElevenLabs_text_to_speech({
text,
voice_id: voiceId
});
const result = await pollAsyncOperation(
() => checkTTSStatus(job_id),
{
maxWait: 60000,
respectETA: false
}
);
return result;
}
Polling Intervals
Recommended Intervals
- Initial poll: 5 seconds
- After first poll: 10 seconds
- After second poll: 20 seconds
- After third poll: 40 seconds (capped)
- Maximum wait: 5 minutes for character generation, 2 minutes for tiles
When to Use Fixed vs Exponential
- Exponential backoff: Long operations (character generation, complex assets)
- Fixed interval: Short operations (tiles, simple assets) or when ETA is reliable
Timeout Handling
Maximum Timeouts by Operation Type
const TIMEOUTS = {
character_generation: 300000,
tile_generation: 120000,
map_object: 180000,
animation: 240000,
default: 300000
};
Graceful Timeout Handling
try {
const result = await pollAsyncOperation(checkStatus, {
maxWait: TIMEOUTS.character_generation
});
return result;
} catch (error) {
if (error.message.includes('timed out')) {
console.warn('Operation timed out, consider retrying or using fallback');
throw new Error('Operation timed out after maximum wait period');
}
throw error;
}
Progress Tracking
Log Progress and ETA
async function pollWithProgress(
checkStatus: () => Promise<StatusResponse>,
options: PollOptions
) {
let attempt = 0;
while (true) {
const status = await checkStatus();
attempt++;
console.log(`Poll attempt ${attempt}: status=${status.status}`);
if (status.eta_seconds) {
console.log(`ETA: ${status.eta_seconds} seconds`);
}
if (status.status === 'completed') {
console.log('Operation completed successfully');
return status;
}
await sleep(calculateWaitTime(status, attempt));
}
}
Common Mistakes
❌ Mistake 1: Fixed Interval Polling (MOST COMMON)
Problem: Using fixed intervals (30s, 60s) instead of exponential backoff
Example:
while (status !== 'completed') {
await sleep(30000);
status = await checkStatus();
}
while (status !== 'completed') {
await sleep(60000);
status = await checkStatus();
}
Why This Is Wrong:
- Wastes 20-40% of wait time
- Polls too frequently early (operation won't be ready)
- Polls too infrequently later (operation may be ready, but we wait full interval)
Correct Solution:
let interval = 5000;
while (status !== 'completed') {
await sleep(interval);
status = await checkStatus();
interval = Math.min(interval * 2, 60000);
}
❌ Mistake 2: Polling Too Frequently
Problem: Polling every 1-2 seconds wastes resources
while (status !== 'completed') {
await sleep(1000);
status = await checkStatus();
}
Why This Is Wrong:
- Wastes API calls
- May hit rate limits
- No benefit (operation won't complete faster)
Correct Solution:
let interval = 5000;
while (status !== 'completed') {
await sleep(interval);
status = await checkStatus();
interval = Math.min(interval * 2, 60000);
}
❌ Mistake 3: Download Before Completion
Problem: Downloading immediately without checking status
const character = await create_character({ description: "wizard" });
download(character.download_url);
Why This Is Wrong:
- Download URL may be null
- File may be locked (HTTP 423)
- Operation may not be complete
Correct Solution:
let character = await create_character({ description: "wizard" });
while (character.status !== 'completed') {
await sleep(interval);
character = await get_character({ character_id: character.character_id });
interval = Math.min(interval * 2, 60000);
}
download(character.download_url);
❌ Mistake 4: Wait Idly
Problem: Just waiting without doing parallel work
await pollAsyncOperation(checkStatus);
Why This Is Wrong:
- Wastes time that could be used for preparation
- No benefit from waiting time
Correct Solution:
const [result, integrationCode] = await Promise.all([
pollAsyncOperation(checkStatus),
prepareIntegrationCode()
]);
❌ Mistake 5: Ignoring API-Provided ETAs
Problem: Not using API-provided ETAs when available
const character = await get_character({ character_id });
await sleep(30000);
Why This Is Wrong:
- API provides accurate ETA
- We wait longer than necessary
- Wastes time
Correct Solution:
const character = await get_character({ character_id });
if (character.eta_seconds) {
await sleep(Math.min(character.eta_seconds * 1000, currentInterval));
}
Common Pitfalls
❌ Don't: Poll Too Frequently
while (status !== 'completed') {
await sleep(1000);
status = await checkStatus();
}
❌ Don't: Download Before Completion
const character = await create_character({ description: "wizard" });
download(character.download_url);
❌ Don't: Wait Idly
await pollAsyncOperation(checkStatus);
const [result, integrationCode] = await Promise.all([
pollAsyncOperation(checkStatus),
prepareIntegrationCode()
]);
Best Practices
- Always use exponential backoff for long operations
- Respect API-provided ETAs when available
- Verify completion before download (status === "completed")
- Use waiting time for parallel work (code preparation, documentation)
- Set appropriate timeouts based on operation type
- Log progress and ETAs for transparency
- Handle HTTP 423 (Locked) errors gracefully
- Never poll more frequently than every 5 seconds
Integration with Other Skills
- game-asset-pipeline: Uses this skill for asset generation polling
- asset-integration-workflow: Uses this skill for async asset operations
- pixellab-mcp: MCP server that benefits from this polling pattern
Related Skills
game-asset-pipeline - Asset generation workflow
asset-integration-workflow - Asset integration patterns
pixellab-mcp - PixelLab MCP server documentation
Remember
- Poll with exponential backoff: 5s → 10s → 20s → 40s
- Respect ETAs: Use API-provided ETAs when available
- Verify before download: Always check status === "completed"
- Do parallel work: Use waiting time productively
- Set timeouts: Don't wait forever
- Log progress: Keep users informed