Lesson 10 of 10

Production Patterns

Reliable polling, error handling by failure mode, retries with backoff, and cost control for real workloads.

Follow along with a live API key. 100 free minutes, no credit card.

Get free API key

You've got all the pieces. Production is about the unhappy paths: jobs that fail, rate limits, signed URLs that expire, and keeping the bill under control. Here's how to make the workflow hold up under real traffic.

Poll for completion (there are no webhooks)

The API doesn't call you back. There's no webhook field on the transcode endpoint, and if you send one it's ignored. You find out a job finished by polling GET /v1/transcodes/{id} until its status is completed or failed. Bound the loop so a stuck job can't poll forever, and raise the ceiling for long videos.

async function waitForJob(jobId, { intervalMs = 3000, maxAttempts = 100 } = {}) {
  const API_URL = 'https://api.ffmpeg-micro.com'
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`${API_URL}/v1/transcodes/${jobId}`, {
      headers: { Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}` },
    })
    const job = await res.json()

    if (job.status === 'completed') return job
    if (job.status === 'failed') {
      throw new Error(`Job ${jobId} failed: ${job.error_message ?? 'unknown'}`)
    }
    await new Promise((r) => setTimeout(r, intervalMs))
  }
  throw new Error(`Job ${jobId} timed out after ${maxAttempts} attempts`)
}

What could go wrong: If you assume a webhook will notify you, a finished job just sits there with nobody listening. Polling is the mechanism. For a 30-minute source, bump maxAttempts so the loop covers the full processing time.

Retry only what's worth retrying

A 429 (rate limited) or a 5xx is usually transient, so back off and try again. A 400 or 401 is permanent: the request or the key is wrong, and retrying just wastes calls. Split them.

async function fetchWithRetry(url, options, { retries = 4 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, options)
    // Permanent client errors: don't retry, surface immediately.
    if (res.status >= 400 && res.status < 500 && res.status !== 429) return res
    // Success: done.
    if (res.ok) return res
    // Transient (429 / 5xx): back off, unless we're out of attempts.
    if (attempt >= retries) return res
    const delay = Math.min(1000 * 2 ** attempt, 15000)
    await new Promise((r) => setTimeout(r, delay))
  }
}

Handle errors by failure mode

Each failure wants a different response. Branch on the HTTP status, and treat a job that reaches failed separately since that comes back on a 200 with the reason in error_message.

try {
  const job = await createTranscode(body)
  const done = await waitForJob(job.id)
  return await getDownloadUrl(job.id)
} catch (err) {
  if (err.status === 401) {
    // Bad or missing key. Check the Authorization header.
  } else if (err.status === 429) {
    // Rate limited. Lower concurrency or back off (see Lesson 9).
  } else if (err.status === 400) {
    // Invalid request. The response details name the bad option or format.
  } else {
    // Job reached "failed" status. err.message carries the reason.
  }
  throw err
}

Control cost before you submit

Billing tracks processing minutes, so the cheapest job is the one you never send. You already have the video's length: the upload confirm response returns metadata.duration_seconds. Gate on it and reject inputs that are too long to be worth processing.

const upload = await uploadFile('input.mp4', 'video/mp4')

const maxSeconds = 60 * 20 // refuse anything over 20 minutes
if (upload.metadata.duration_seconds > maxSeconds) {
  throw new Error(`Video too long: ${upload.metadata.duration_seconds}s`)
}

const job = await createTranscode({
  inputs: [{ url: upload.fileUrl }],
  outputFormat: 'mp4',
  preset: { quality: 'medium', resolution: '720p' },
})

Keep an eye on usage in the dashboard, where your remaining minutes and plan limits live.

Treat download URLs as short-lived

The URL from /download is signed and expires after 10 minutes. Request it right before you download, not ahead of time, and just call /download again if it lapses.

What could go wrong: Storing a download URL in a database or queuing it for later means it's often expired by the time you use it and returns a 403. Fetch a fresh one at download time.

Key takeaways

  • Detect completion by polling. The API has no user webhooks.
  • Bound your polling loop, and raise the ceiling for long videos.
  • Retry 429 and 5xx with backoff. Never retry 400 or 401.
  • Branch error handling by status, and read error_message on failed jobs.
  • Gate cost with duration_seconds before submitting.
  • Download URLs expire in 10 minutes. Fetch them right before use.

That's the whole pipeline: upload, transcode, poll, download, and everything around it that makes it production-ready. You can now build a complete video workflow in Node.js without ever installing FFmpeg or running a server. Grab the runnable code from the companion repo on GitHub and keep the API reference handy as you build.

Build this into your app. No FFmpeg install

One call kicks off a job. No local FFmpeg, no servers, no worker queue to run.

const res = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    inputs: [{ url: 'gs://your-bucket/input.mp4' }],
    outputFormat: 'mp4',
    preset: { quality: 'medium', resolution: '1080p' },
  }),
})
const job = await res.json() // { id, status: 'pending' }