DEV Community

Cover image for How to Design Credit-Safe Background Jobs in SaaS
Shruti Saraswat
Shruti Saraswat

Posted on

How to Design Credit-Safe Background Jobs in SaaS

A customer uploads a file, spends one credit, and processing begins in the background.

Thirty seconds later, the worker fails.

Now the product has several questions to answer at once. Was the credit actually consumed? Can the job retry without charging again? Is the uploaded file still available? What should the customer see? And if they click the button again because they think nothing happened, is that a new job or the same logical operation?

These are easy questions to ignore while the happy path is working. They become much harder to ignore once paid usage depends on asynchronous processing.

A clean way to handle this is to separate job state from usage state, connect them through one logical operation, and make every transition explicit.

This guide walks through that pattern.

Start With Two Different State Machines

The background job and the customer's credit do not describe the same thing.

A job might be:

type JobStatus =
  | "queued"
  | "processing"
  | "completed"
  | "failed";
Enter fullscreen mode Exit fullscreen mode

The related usage might be:

type CreditStatus =
  | "reserved"
  | "consumed"
  | "released";
Enter fullscreen mode Exit fullscreen mode

Keeping those states separate matters because a failed job does not automatically tell you what should happen financially. Likewise, seeing that one credit was reserved does not tell you whether the work completed successfully.

The application needs an explicit relationship between the two.

A useful starting model is:

type CreditReservation = {
  id: string;
  userId: string;
  jobId: string;
  amount: number;
  status: CreditStatus;
};
Enter fullscreen mode Exit fullscreen mode

Now a customer action can be traced through both systems without relying on scattered booleans such as charged, processed, or refunded.

Reserve Usage Before the Expensive Work Begins

A fragile implementation often deducts the credit somewhere deep inside the worker:

async function processJob(job: Job) {
  await deductCredit(job.userId);

  const result = await runProcessing(job);

  await saveResult(result);
}
Enter fullscreen mode Exit fullscreen mode

This looks simple until runProcessing() throws.

At that point, usage has already changed while the work has not completed. If the worker retries, another part of the application now has to remember that the first attempt already touched the balance.

A reservation model gives the workflow a cleaner sequence:

Customer starts job
        ↓
Check available credits
        ↓
Reserve required usage
        ↓
Create background job
        ↓
Process work
     ↙       ↘
 Success     Failure
    ↓          ↓
Consume      Apply failure
reservation  policy
Enter fullscreen mode Exit fullscreen mode

The reserved amount is unavailable for another customer action, but it has not yet reached its final state.

That gives the application room to decide what should happen after the job outcome is known.

Create the Job and Reservation Together

If the job is created successfully but the reservation fails, or the reservation succeeds but the job never reaches the queue, you have another inconsistency.

Those operations should therefore happen inside the same transactional boundary whenever the storage model allows it.

async function createPaidJob(
  userId: string,
  operationKey: string
) {
  return db.transaction(async (tx) => {
    const existing = await tx.job.findUnique({
      where: { operationKey }
    });

    if (existing) {
      return existing;
    }

    const account = await tx.creditAccount.findUnique({
      where: { userId }
    });

    if (!account || account.available < 1) {
      throw new Error("Insufficient credits");
    }

    const job = await tx.job.create({
      data: {
        userId,
        operationKey,
        status: "queued"
      }
    });

    await tx.creditAccount.update({
      where: { userId },
      data: {
        available: { decrement: 1 },
        reserved: { increment: 1 }
      }
    });

    await tx.creditReservation.create({
      data: {
        userId,
        jobId: job.id,
        amount: 1,
        status: "reserved"
      }
    });

    return job;
  });
}
Enter fullscreen mode Exit fullscreen mode

There are two useful ideas here.

The job and the credit reservation begin together, and the operationKey gives duplicate customer submissions somewhere to converge.

Treat Retries as Attempts of the Same Job

Infrastructure retries should not look like new customer purchases.

Imagine the worker calls an external processing service and times out. The queue retries the job. On the third attempt, processing completes successfully.

From the customer's perspective, this was still one request.

Model that explicitly:

type Job = {
  id: string;
  operationKey: string;
  status: JobStatus;
  attempt: number;
  maxAttempts: number;
  reservationId: string;
};
Enter fullscreen mode Exit fullscreen mode

Then the sequence becomes:

One customer request
      ↓
One job
      ↓
One reservation
      ↓
Attempt 1 fails
      ↓
Attempt 2 fails
      ↓
Attempt 3 succeeds
      ↓
One final usage decision
Enter fullscreen mode Exit fullscreen mode

The retry belongs to the job, not to the billing system.

That distinction prevents infrastructure instability from quietly turning into duplicate customer usage.

Make Completion Idempotent

Queue systems may deliver the same completion event more than once. Workers can restart at awkward times. Network acknowledgements can disappear.

The final credit transition therefore needs to be safe when called repeatedly.

async function consumeReservation(
  reservationId: string
) {
  return db.transaction(async (tx) => {
    const reservation =
      await tx.creditReservation.findUnique({
        where: { id: reservationId }
      });

    if (!reservation) {
      throw new Error("Reservation not found");
    }

    if (reservation.status === "consumed") {
      return reservation;
    }

    if (reservation.status !== "reserved") {
      throw new Error(
        `Cannot consume ${reservation.status}`
      );
    }

    await tx.creditAccount.update({
      where: { userId: reservation.userId },
      data: {
        reserved: {
          decrement: reservation.amount
        }
      }
    });

    return tx.creditReservation.update({
      where: { id: reservation.id },
      data: { status: "consumed" }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The same principle should apply to release logic.

Calling the transition twice should not return the same credit twice or consume it twice.

Define Failure Policies Instead of Treating Every Failure the Same

“Job failed” is not enough information for a paid workflow.

The system may fail because your worker crashed, an external provider was unavailable, the customer uploaded an unsupported file, or the customer cancelled the operation deliberately.

Those situations may deserve different product behavior.

Represent the reason:

type FailureReason =
  | "invalid_input"
  | "unsupported_file"
  | "provider_failure"
  | "worker_failure"
  | "customer_cancelled"
  | "unknown";
Enter fullscreen mode Exit fullscreen mode

Then define the usage policy separately:

type FailurePolicy =
  | "release_credit"
  | "consume_credit"
  | "manual_review";
Enter fullscreen mode Exit fullscreen mode

For example:

function resolveFailurePolicy(
  reason: FailureReason
): FailurePolicy {
  switch (reason) {
    case "provider_failure":
    case "worker_failure":
      return "release_credit";

    case "unsupported_file":
    case "invalid_input":
      return "manual_review";

    default:
      return "manual_review";
  }
}
Enter fullscreen mode Exit fullscreen mode

That mapping is product-specific. Do not copy the example blindly into a billing system.

The useful architectural decision is having the mapping at all.

When the rule is explicit, product, engineering, support, and finance can all understand what the system is supposed to do.

Keep an Event History, Not Just a Balance

A current balance tells you where the account ended up.

It does not explain how it got there.

For paid workflows, keep an immutable or append-oriented usage history where practical.

type CreditLedgerEntry = {
  id: string;
  userId: string;
  jobId?: string;
  type:
    | "purchase"
    | "reserve"
    | "consume"
    | "release"
    | "manual_adjustment";
  amount: number;
  createdAt: Date;
};
Enter fullscreen mode Exit fullscreen mode

A customer history could then read:

+20 credits purchased
-1 reserved for job_123
+1 released after processing failure
-1 reserved for job_124
-1 consumed after successful completion
Enter fullscreen mode Exit fullscreen mode

That history is useful for much more than accounting.

When support receives a message saying, “My credit disappeared,” the team can see what happened without asking an engineer to reconstruct the event from logs.

Make the Processing State Visible to the Customer

A balance changing with no explanation creates unnecessary uncertainty.

If a credit is reserved while the job is still running, the product should make that state understandable.

For example:

Processing your file

1 credit is currently reserved for this job.

You will see the final usage state when processing completes.
Enter fullscreen mode Exit fullscreen mode

The exact copy depends on the product and the charging policy, but the principle is straightforward: customer-visible state should reflect backend state closely enough that people do not need to guess.

This also reduces duplicate submissions.

If the interface clearly says that the same job is still processing, the user has less reason to click the action again.

Use an Idempotency Key for Repeated Customer Actions

Double-clicks and repeated submissions are normal.

A customer may press Generate, see no immediate result, refresh the page, and press Generate again.

The product needs a way to decide whether that is genuinely a new request.

type CreateJobInput = {
  userId: string;
  fileId: string;
  operation: string;
  idempotencyKey: string;
};
Enter fullscreen mode Exit fullscreen mode

Before creating another job:

async function createJob(
  input: CreateJobInput
) {
  const existing = await db.job.findUnique({
    where: {
      idempotencyKey: input.idempotencyKey
    }
  });

  if (existing) {
    return existing;
  }

  return createPaidJob(
    input.userId,
    input.idempotencyKey
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the interface can return the existing operation instead of reserving another credit.

Reconcile the System in the Background

Even carefully designed systems benefit from reconciliation.

A scheduled process can look for states that should never exist:

  • completed job with an open reservation
  • failed job with consumed usage when policy says release
  • reserved account balance that does not match active reservations
  • background job with no associated usage record
  • reservation attached to a missing job

Do not wait for customers to discover those inconsistencies.

Represent them as operational issues:

type ReconciliationIssue = {
  jobId?: string;
  reservationId?: string;
  issue: string;
  detectedAt: Date;
};
Enter fullscreen mode Exit fullscreen mode

Now credit integrity becomes something the team can monitor.

Give Support a Readable Operational View

The internal team should be able to answer a customer's question without opening database tables.

A support-facing record might show:

Job
job_123

Status
Failed

Attempts
3

Failure reason
Provider unavailable

Credit
1 reserved
1 released

File
Stored successfully

Customer action needed
Retry available
Enter fullscreen mode Exit fullscreen mode

This view is simple, but it connects several systems that otherwise tend to become separate engineering concerns.

The user sees one failed job.

Support should see one understandable story behind it.

A Practical End-to-End Flow

The complete path can stay conceptually simple:

Customer starts processing
        ↓
Validate request
        ↓
Create logical job
        ↓
Reserve usage
        ↓
Queue background work
        ↓
Process + retry if necessary
        ↓
Final outcome?
   ↙                 ↘
Success             Failure
  ↓                    ↓
Consume            Apply failure
usage              policy
   ↘                 ↙
      Final job state
            ↓
 Customer-visible status
            ↓
  Support/audit history
Enter fullscreen mode Exit fullscreen mode

The important design decision is that every transition leaves the product in a state it can explain.

Why This Matters When SaaS Starts Charging for Usage

We recently worked on an MVP to Production SaaS Upgrade where paid plans, credits, file workflows, failed-run handling, customer access, hosting, monitoring, and support visibility needed to work together more reliably.

The public case study describes clearer credit behavior around paid usage and safer handling of failed runs, along with stronger visibility for the founder when issues occurred.

You can see the public project breakdown here:

https://ascentinnovate.com/work/mvp-to-production-saas

The implementation inside another SaaS product may be completely different. Some products charge when external compute has already been consumed. Others refund automatically. Some use subscriptions without credits at all.

The architecture still needs to answer the same operational question:

Can the product explain what happened to customer usage when asynchronous work succeeds, fails, retries, or never completes?

Shipping Checklist

Before releasing a credit-based background workflow, verify that:

1. One customer action maps to one logical job

Retries and duplicate clicks should not silently create additional usage.

2. Usage has explicit states

Reserved, consumed, and released should mean different things.

3. Failure policies are defined

Engineering should not invent billing behavior during an incident.

4. Every usage transition is traceable

Support should be able to explain a balance change.

5. Customer messaging matches backend state

Users should know whether work is processing, retrying, failed, or complete.

6. Reconciliation exists

Impossible states should be detected before they become support tickets.

7. Internal visibility exists

The team should see enough context to resolve problems without manually reconstructing the workflow.

Paid background processing becomes much easier to operate when the system can tell one coherent story from the customer's click to the final usage state.

Top comments (0)