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";
The related usage might be:
type CreditStatus =
| "reserved"
| "consumed"
| "released";
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;
};
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);
}
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
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;
});
}
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;
};
Then the sequence becomes:
One customer request
↓
One job
↓
One reservation
↓
Attempt 1 fails
↓
Attempt 2 fails
↓
Attempt 3 succeeds
↓
One final usage decision
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" }
});
});
}
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";
Then define the usage policy separately:
type FailurePolicy =
| "release_credit"
| "consume_credit"
| "manual_review";
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";
}
}
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;
};
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
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.
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;
};
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
);
}
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;
};
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
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
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)