Skip to main content
Skip to main content
Back to the blog

Backend Foundations for AI Products: Data, Queues and Control

About the Author

This article was created by Usman Mughal, the person who built this company and leads our engineering team.

11 min readEditorial standards
Backend Foundations for AI Products: Data, Queues and Control

In the AI products we have built and the ones we have been called in to rescue, the model is rarely the thing that broke. The failures are ordinary backend failures, made sharper by three properties that model calls have and typical backend operations do not: they are slow, they are expensive, and they sometimes fail in ways that look like success.

Here is what that changes about the system around the model.

Long operations do not belong in the request cycle

A model call can take seconds. Sometimes tens of seconds. A chain of them can run for minutes. Handling that inside an HTTP request produces a familiar set of problems: gateway timeouts at thirty or sixty seconds, retries that silently duplicate expensive work, a mobile client on a train that loses the connection and loses the result with it.

The pattern that works is the one long-running systems have always used. Accept the request, persist a job record, return an identifier immediately. Process in a background worker. Let the client poll or subscribe for the result. Stream partial output where it improves the experience.

ts
// Accept, persist, return. The model is never called inside the request.
export async function createSummary(req, res) {
  const job = await db.job.create({
    data: { type: "summary", input: req.body, status: "queued" },
  });

  await queue.publish("summary", { jobId: job.id });

  // 202, not 200. The work has been accepted, not completed.
  res.status(202).json({ jobId: job.id, status: job.status });
}

This is not exotic infrastructure, but it is a decision that has to be made early, because retrofitting it means changing every client that assumed a synchronous response. The API contract, the mobile app, the error handling, all of it.

Swimlane flow diagram split into two lanes by a dashed line labelled: nothing below this line blocks the request. The upper lane, headed request cycle, milliseconds, shows a client sending POST /summaries to the API. The API persists a job record with status queued and publishes to a queue, and an arrow returns straight to the client carrying 202 Accepted with a jobId, not 200. The lower lane, headed background work, seconds to minutes, begins with the queue, marked at-least-once delivery so the same message can arrive twice. Flow reaches a decision: has this idempotency key already been seen? The yes branch returns the stored result and runs down the side of the diagram, bypassing the model provider entirely. The no branch continues into a guards step, where a rate limit, a budget ceiling and a circuit breaker all run before any money is spent, and only then reaches the model provider, drawn with a dashed border as the one expensive call in the flow. Its output goes to a validate and log step that treats the output as untrusted input and records the whole interaction, not just the answer. Both branches end at a job record marked status done. A separate dashed arrow runs down the left from the client to that job record, showing the client polling GET /jobs/id until the work completes.
The whole shape in one picture. The guards are the subject of the next two sections; what matters here is that they sit before the model call, and that a repeated request never reaches it at all.

Idempotency is not optional when calls cost money

Retries are a fact of distributed systems. Networks drop, workers restart, users double-tap. In conventional software a duplicated request writes the same row twice and you clean it up. When the operation is a model call, every duplicate is money, and a job queue with an at-least-once delivery guarantee will deliver duplicates. This is documented behaviour rather than a failure: SQS standard queues state plainly that a message can be delivered more than once, and most queues make the same trade.

So every expensive operation needs an idempotency key, derived from the input, or supplied by the client, checked before the work is done and recorded after. Take the same request twice, do the work once, return the same answer. Payment APIs solved this years ago and the pattern transfers directly: Stripe's idempotent requests are worth reading even if you never take a payment, because the failure they prevent is exactly yours.

ts
// The key is derived from the input, so a user double-tapping and a worker
// retrying both land on the same row.
function idempotencyKey(input) {
  return crypto.createHash("sha256").update(JSON.stringify(input)).digest("hex");
}

async function runOnce(input) {
  const key = idempotencyKey(input);

  const existing = await db.result.findUnique({ where: { key } });
  if (existing) return existing;

  const output = await model.complete(input);

  // A concurrent worker may have won the race since that read, so the write
  // has to be safe on its own. The unique constraint on `key` is what makes
  // this correct; checking first is only an optimisation.
  return db.result.upsert({ where: { key }, create: { key, output }, update: {} });
}

The same mechanism doubles as a cache. If two users ask an identical question, or one user asks twice, you already have the answer stored. That is the cheapest cost saving available and it falls out of doing idempotency properly.

Every model call needs a budget and a ceiling

A conventional backend degrades under load: things get slow, queues back up, someone gets paged. An AI backend under the same load keeps working and bills you for it. There is no natural back-pressure, because the provider is happy to serve every request.

So the limits have to be explicit, and they have to exist in the code rather than in someone's intention:

  • Per-user and per-account rate limits, applied before the expensive call, not after.
  • A hard cap on chain length. Any loop where the model decides whether to continue needs a maximum iteration count. Without it, one malformed input can spin until someone notices.
  • Timeouts on every call, with a defined behaviour when they fire.
  • A circuit breaker for provider outages, so a degraded provider does not turn into a queue of retries that all fail expensively.

We go deeper on the economics of this in shipping AI features without wrecking your unit economics; this is the enforcement layer for it.

Model outputs are untrusted input

This is the mental shift most teams make too late. A model's response is not a return value from your own code: it is closer to a form submission from a stranger. It usually has the right shape. Occasionally it does not.

Which means the boundary needs the same treatment any untrusted input gets. Validate the structure against a schema before anything downstream touches it. Have a defined path for when validation fails: retry once, fall back, surface an error, rather than letting a malformed response propagate. Never interpolate model output into a query, a shell command, or a template that executes.

ts
const Invoice = z.object({
  total: z.number().positive(),
  currency: z.enum(["GBP", "USD", "EUR"]),
  dueDate: z.coerce.date(),
});

async function extractInvoice(document) {
  const parsed = Invoice.safeParse(tryJson(await model.complete(EXTRACT, document)));
  if (parsed.success) return parsed.data;

  // One retry with a stricter prompt, then a defined failure. What must never
  // happen is a half-parsed object continuing downstream, or the raw string
  // being handed to something that will act on it.
  const retry = Invoice.safeParse(tryJson(await model.complete(STRICTER, document)));
  if (retry.success) return retry.data;

  throw new ExtractionFailed(parsed.error, { document });
}

That last one deserves emphasis when the model is summarising documents that users upload. Content written by a third party, passed through a model, and acted on by your system is a genuine injection path, and it is tested for far less often than it should be.

Store the whole interaction, not just the answer

The instinct is to store the output and discard the rest. Six weeks later, when a customer reports a bad result, you have the output and no way to explain it.

Persist enough to reconstruct any request: the input, the prompt version, the model and parameters, what was retrieved if anything, the raw response, token counts, latency, and cost. This is one table, and it is the single highest-use thing you can build early.

It is what turns "the AI is being weird" into a specific, reproducible case. It is where the evaluation set comes from, real failures beat invented test cases. It is how you answer whether last week's change helped. And it is how you find out that quality dropped because the provider updated the model behind a stable name, without a line of your code changing.

If you cannot reconstruct exactly what produced a bad answer, you cannot fix it, you can only change something and hope.

Do not marry a provider you cannot leave

Provider APIs look similar enough that teams call them directly from feature code, and then discover the cost of that when they want to move: to a cheaper model for simple tasks, to a second provider for redundancy, or away from one whose terms changed.

A thin internal interface is enough. Your application asks for a completion with a task name and inputs; behind that, one place decides which provider and model serves it, applies the timeout and retry policy, and does the logging. That indirection costs an afternoon and buys three things worth much more than it: routing easy tasks to cheaper models without touching feature code, failing over during an outage, and A/B testing a model change on a fraction of traffic.

The failure mode to avoid is the opposite extreme: a heavyweight abstraction attempting to normalise every provider's capabilities. That ages badly, because the interesting features are exactly the ones that differ. Keep it thin, and let it leak deliberately where you need something specific.

Streaming changes the shape of the endpoint

If your interface streams output as it is generated, the backend contract changes more than it first appears, and a few things routinely get missed.

You still need the complete response persisted server-side once generation finishes: for logging, cost accounting and the eval set. A client that assembled the text does not help you when it disconnects halfway.

You need a defined behaviour for a stream that fails mid-flight. The user has already read three paragraphs; the fourth never arrives. Silently stopping looks like a finished answer that trails off, which is worse than an error.

And validation gets harder, because you cannot schema-check output you have not finished receiving. Where output must satisfy a contract, either stream a non-structured part and validate the structured part separately, or accept that validation happens at the end and design the interface for a late failure.

None of this is a reason to avoid streaming, it is a better experience for long generations. It is a reason to treat it as an architectural decision rather than a display option.

Prompts are code, so version them

A prompt sitting in a string literal, edited in production to fix an issue, is an undocumented change to system behaviour with no review, no history, and no way to roll back.

Prompts belong in version control, changed through the same review process as any other code, with a version identifier recorded against every request that used them. Then a quality regression is traceable to a specific change on a specific date, which is the difference between a ten-minute fix and a week of archaeology.

The data model still decides everything

The oldest lesson in backend engineering survives contact with AI entirely intact: a generative layer over a confused schema produces confident nonsense.

If your customer records are duplicated across three tables with no clear owner, retrieval will surface all three and the model will blend them into an answer that is fluent and wrong. If permissions are enforced in the UI rather than the data layer, retrieval will happily fetch a document the user should never see and quote it back to them. That second one is not a quality bug, it is a data breach, and it is the failure mode we see most often in retrieval systems built quickly.

Getting the schema and the permission model right is unglamorous work that no demo shows off. It is also the work that determines whether the impressive demo becomes a product.

Vidu is where we learned most of this. It orchestrates text-to-image, image-to-video, 3D synthesis and a custom model-training pipeline behind one backend, which means job queuing, GPU cost, latency, retries and output quality are all live concerns at once, across 15 languages and three Apple platforms. None of that difficulty is in the model call. All of it is in the system around it.

The short list

Before the model call is interesting, these should be true:

  • Long operations run in the background, with a job record the client can poll.
  • Expensive operations are idempotent, and the idempotency store doubles as a cache.
  • Rate limits, chain-length caps, timeouts and a circuit breaker exist in code.
  • Model output is schema-validated at the boundary, with a defined failure path.
  • Every interaction is logged in full, inputs, versions, cost, latency.
  • Prompts are versioned in the repository and stamped on every request.
  • Permissions are enforced in the data layer, below anything retrieval can reach.

None of it is novel. All of it is what separates an AI demo from an AI product, and it is the substance of what our backend engineering work consists of.

If you want the same list widened past the backend to cover evaluation, cost ceilings, logging and the rest, the AI production readiness checklist is the version we walk through before a launch date is agreed, and it is the shape our AI engineering engagements take.

BackendArchitectureAI Engineering