Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

DB-Backed Job Queue with Retry

A Postgres-backed job queue with exponential backoff retry handles any work too slow for a synchronous Edge Function (image processing, email sends, LLM calls), ensuring no job is silently dropped even if the worker crashes mid-execution.

  • Tier: Core
  • Status: Static
  • Always enabled

WHY IT MATTERS

Every mobile backend eventually encounters work that cannot finish within the lifetime of a single HTTP request: sending a transactional email through a third-party provider, processing an uploaded image, calling an LLM for a user-triggered generation, or building a data export archive. The standard response is to fire an async task and hope for the best. If the Edge Function times out, the server restarts, or the external API returns a 503, the work is gone. The user sees nothing happened. The gas-template solves this with a Postgres-backed job queue that turns every unit of async work into a durable row. Enqueueing is a single INSERT into the jobs table: a kind string identifies the handler, a payload JSONB carries the arguments, and available_at controls when the job becomes eligible for dispatch. The row exists in your database even if the worker never runs, which means nothing is silently dropped. Jobs survive Edge Function cold starts, worker crashes, and transient downstream outages without any application-level retry logic.

The claim-lock pattern at the core of the queue is what makes it safe under concurrent workers. The claim_jobs() Postgres function uses SELECT ... FOR UPDATE SKIP LOCKED inside a CTE to atomically select and transition rows from pending to running, incrementing the attempts counter in the same statement. Two simultaneous worker invocations will never claim the same job: SKIP LOCKED means the second worker bypasses any row already held by the first. When a job finishes successfully, complete_job() transitions it to succeeded and writes the result JSON. When it fails, fail_job() checks the attempts counter against max_attempts. If there are remaining attempts, it resets the row to pending with an exponential backoff delay: 2^attempts multiplied by thirty seconds, capped at one hour. After max_attempts is exhausted the row moves to dead, where it stays observable without clogging the pending index. Unknown job kinds skip the retry logic entirely and go directly to dead, because an unknown kind is a misconfiguration rather than a transient failure. The worker itself is a Supabase Edge Function invoked by pg_cron once per minute. It claims up to ten jobs per tick, dispatches each to a typed handler registry, enforces a 45-second per-job timeout via AbortController, and reports any unhandled exception to Sentry.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from supabase/functions/job-worker/index.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// supabase/functions/job-worker/index.ts
// Claims up to 10 pending jobs and dispatches each to a handler keyed by kind.
// Invoked by pg_cron every minute. Auth: CRON_SECRET Bearer token.

const handlers: Record<string, Handler> = {
  send_push: handleSendPush,
  send_email: handleSendEmail,
  dispatch_outbound_webhook: handleDispatchOutboundWebhook,
  oauth_refresh: handleOauthRefresh,
  // DevAgent appends app-specific kinds here.
};

serve(async (req: Request) => {
  const denied = requireCronBearer(req);
  if (denied) return denied;

  const jobs = await claimJobs(10, 'job-worker'); // atomic FOR UPDATE SKIP LOCKED
  const results = await Promise.all(jobs.map((j) => runOne(j)));
  return json({ processed: results.length, results });
});

async function runOne(job: Job) {
  const handler = handlers[job.kind];
  if (!handler) {
    // Unknown kind = misconfiguration, not retryable. Mark dead immediately.
    await serviceClient().from('jobs').update({
      status: 'dead', last_error: `unknown_kind: ${job.kind}`,
      locked_at: null, locked_by: null, updated_at: new Date().toISOString(),
    }).eq('id', job.id);
    return { id: job.id, status: 'failed' };
  }
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(new Error('handler_timeout')), 45_000);
  try {
    const result = await handler(job.payload, ac.signal);
    await completeJob(job.id, result);  // status -> succeeded
    return { id: job.id, status: 'succeeded' };
  } catch (e) {
    await failJob(job.id, e instanceof Error ? e.message : String(e));
    // fail_job: exponential backoff (2^attempts * 30s, cap 1h), or dead at max_attempts
    return { id: job.id, status: 'failed' };
  } finally {
    clearTimeout(timer);
  }
}

Source: goodspeed-apps/gas-template supabase/functions/job-worker/index.ts

HONEST LIMITS

When DB-Backed Job Queue with Retry is the wrong choice

The job queue adds latency that is unnecessary for fast operations. Every job waits for the next pg_cron tick, which fires once per minute. If your Edge Function can complete the work in under 100 milliseconds, the queue imposes a round-trip to Postgres, a one-minute maximum scheduling delay, and an additional pg_cron heartbeat cost in exchange for no observable reliability benefit. The right threshold is roughly the Edge Function timeout: if the operation reliably finishes within the timeout window and does not depend on an external API that could be down for minutes at a time, run it synchronously. The queue is the right tool when the work is slow, when it must survive a worker crash, or when you need observable retry history in a SQL table. Also avoid the queue for work that must complete before the user receives an HTTP response. The claim-poll cycle means a newly enqueued job is processed on the next worker tick, not immediately. For operations where the user is waiting on screen for a result, such as a payment confirmation or a form submission acknowledgement, a synchronous Edge Function call or a Supabase Realtime subscription is the right architecture. The job queue is designed for fire-and-forget work that the user does not need to wait on: background processing, deferred notifications, periodic housekeeping, and anything that benefits from retry semantics when an external service is temporarily unavailable.

Tier: Core · Static

  1. Evaluate your use case

    Check whether db-backed job queue with retry aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes db-backed job queue with retry. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

DB-Backed Job Queue with Retry capability breakdown

Concrete dimensions of what the built-in db-backed job queue with retry implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Storage backendJobs are rows in the Supabase Postgres jobs table. The table survives Edge Function restarts, cold starts, and transient outages. No in-memory queue state is lost.Supabase Postgres
Claim-lock patternclaim_jobs() uses SELECT ... FOR UPDATE SKIP LOCKED inside a CTE to atomically transition rows from pending to running. Two concurrent workers never claim the same job.Atomic SKIP LOCKED
Retry strategyfail_job() applies exponential backoff: 2^attempts multiplied by thirty seconds, capped at one hour. After max_attempts (default 5) the row moves to dead, observable without clogging the pending index.Exponential backoff, dead-letter
Worker invocationThe job-worker Edge Function is triggered by pg_cron once per minute with a CRON_SECRET Bearer token. Each invocation claims up to 10 jobs and dispatches them concurrently via a typed handler registry.pg_cron (1-minute interval)
Per-job timeoutEach handler runs under a 45-second AbortController timeout. Timed-out jobs are recorded as failures and re-queued with backoff, not silently abandoned.45-second hard limit

COMMON QUESTIONS

How do I enqueue a job from my own Edge Function?

Call enqueueJob() from supabase/functions/_shared/jobs.ts. The function takes a kind string identifying the handler, an optional payload object, an optional availableAt Date for deferred scheduling, and an optional maxAttempts integer. It performs a single INSERT into the jobs table using the service-role client and returns the new row UUID. The handler for that kind must be registered in the handlers map in supabase/functions/job-worker/index.ts. If you pass a kind that is not in the map, the worker will claim the job on the next tick, detect the missing handler, and immediately transition the row to dead with an unknown_kind error message, so misconfigured kinds surface quickly without consuming retry budget. Enqueueing is intentionally side-effect-free from the caller perspective: you insert a row and move on. The job runs on the next worker tick, which fires within one minute.

What happens if the worker crashes mid-execution?

When the worker claims a job it sets the row to running and increments the attempts counter in the same atomic UPDATE. If the Edge Function is killed before it calls completeJob() or failJob(), the row stays in the running state permanently. To prevent jobs from getting stuck in running indefinitely, migration 006_async_backbone_watchdog.sql adds a pg_cron-scheduled watchdog function that finds any job with status running and locked_at older than a configurable threshold (default ten minutes) and resets it to pending, clearing locked_at and locked_by. On the next worker tick the job is eligible for re-claim and the attempts counter already reflects the failed attempt, so the backoff calculation and the max_attempts limit both apply correctly. The watchdog threshold should be set higher than the longest legitimate handler runtime to avoid resetting jobs that are genuinely in progress on a slow external API call.

Can I schedule a job to run at a specific future time rather than immediately?

Yes. Pass an availableAt Date to enqueueJob(). The jobs table has an available_at column that defaults to now(). The claim_jobs() function filters on status pending AND available_at less than or equal to now(), so a job with an available_at in the future will not be claimed until that timestamp passes. The pg_cron worker fires every minute, so the effective scheduling resolution is approximately one minute. For most deferred-work use cases, such as sending a follow-up email a day after signup or scheduling a data export at the end of a billing period, one-minute granularity is more than sufficient. If you need sub-minute scheduling precision, call the Edge Function directly rather than enqueueing a job, because the queue cron-based dispatch is not designed for real-time scheduling.

How do I monitor which jobs failed and why?

Every terminal job row in the jobs table carries a last_error text column populated by fail_job() with the exception message from the handler, and a status column that is either succeeded, failed (still has remaining attempts), or dead (exhausted all attempts). To query recently failed jobs, run SELECT id, kind, last_error, attempts, max_attempts, updated_at FROM jobs WHERE status is dead ORDER BY updated_at DESC LIMIT 50. The admin dashboard built into the gas-template exposes a job health panel that surfaces dead job counts grouped by kind so operators can spot a misconfigured handler or a downstream outage at a glance without writing SQL. Sentry also captures every unhandled exception from the worker via reportException(), so failures appear in your error monitoring dashboard with the job ID and kind attached as context tags, making it easy to correlate a spike in Sentry errors with a specific job kind.

GET IT BUILT INTO YOUR APP

Score your idea and get db-backed job queue with retry wired in from day one