Busy front end
0-day streakReview

Your progress

Level 1

0 XP100 XP to level 2
0day streak
0best streak
0topics started
0cards reviewed
    Review cards

    Progress is stored only in this browser. No account is required.

    Layer 4 · Full breakdown · 19 min · 5 figures

    Busy front end

    Why resizing photos, rendering PDFs and parsing bulk imports inside web request handlers starves cheap requests of CPU, threads and memory, how to spot it per route, and how queues, 202 Accepted and worker tiers fix it.

    A busy front end is a web or API tier that does heavy work inside the same request handlers that serve everyone else: resizing photos, rendering PDF statements, parsing a 50 MB bulk import, running a model, or sending ten thousand emails. That work shares cores, threads, memory and the event loop with the cheap requests, so a login that needs 2 ms of CPU waits behind a resize that needs 400. The fix is to keep the request path for work the user must wait for, and hand everything else to a queue and a worker tier built for it.

    Picture it first: the counter and the travel agent

    A railway reservation counter serves one person at a time, and most need a minute. At 10 am a travel agent arrives with 40 group-booking forms for a wedding party. The clerk works through them at the window for 40 minutes, and the 30 people behind, each wanting one ticket, wait 40 minutes too.

    The station’s fix is a back office. The agent drops the forms in a tray, takes a numbered token, and comes back later or waits for an SMS. The windows go back to one-minute jobs, and the back office works through the tray with as many clerks as the pile needs.

    Map it back:

    • The window clerk is a web worker: a thread, a process, or the event loop.
    • The one-ticket passengers are cheap requests: login, browse, health checks.
    • The group forms are heavy work: photo resizing, PDF statements, bulk imports.
    • The tray is a queue, the token is a 202 Accepted with a status URL, the SMS is a webhook or push notification, and the back office is a worker tier.

    Where the time goes: three budgets

    Every request spends from budgets the whole pod shares: CPU, request slots and memory. Heavy work spends far more of each, and when any one runs out, every route on the pod pays.

    CPU: seconds of work per second

    Take a Swiggy-style platform where the customer app and the restaurant partner app share one API deployment of 8-vCPU pods. Browsing costs 2 ms of CPU per request, at 1,500 a second per pod. Around 11 am restaurants refresh their menus, and each dish photo upload decodes a 12-megapixel image and writes three WebP sizes: 400 ms of CPU in a profile.

    browse only:       1,500 req/s × 2 ms   =  3 CPU-s/s  →  38% of 8 vCPUs
    + 10 uploads/s:       10 × 400 ms       =  4 CPU-s/s  →  7 in total, 88%
    + 20 uploads/s:       20 × 400 ms       =  8 CPU-s/s  → 11 in total, 138% ✗
    pod saturates at:  (8 − 3) ÷ 0.4 s      = 12.5 uploads/s

    Twenty uploads a second fill the pod on their own, and 12.5 tip it over at today’s browse traffic, though uploads are only about 1% of requests.

    Cheap requests slow down well before 100%, because the operating system time-slices runnable threads across the cores. A rough processor-sharing estimate is time ≈ CPU needed ÷ (1 − utilisation): the 2 ms browse request takes about 3.2 ms at 38%, 16 ms at 88% and 80 ms at 97.5%; above 100% the run queue grows until clients time out. The busy database article draws that curve.

    Memory comes along too: a 12-megapixel photo decoded to RGB is 12,000,000 × 3 bytes = 36 MB before resizing starts.

    CPU demand on one 8-vCPU web pod as dish-photo uploads rise. Browse traffic stays fixed at 1,500 requests a second × 2 ms of CPU; each upload costs 400 ms.Notice The uploads, a tiny share of requests, decide whether the pod has room. Past 12.5 uploads a second the 2 ms browse request has no upper bound on its wait.

    Threads and workers: Little’s law

    Most servers have a fixed number of request slots: a thread pool, pre-forked worker processes that each handle one request at a time, or a connection cap. Little’s law says how many are busy:

    slots in use = arrival rate × time each request holds a slot

    Browse at 1,500 requests a second, taking 10 ms including its database call, holds 1,500 × 0.010 = 15 threads. Let the CPU saturate so it takes 150 ms, and it holds 1,500 × 0.15 = 225, more than a 200-thread pool. A slow CPU becomes pool exhaustion, and new requests wait in the accept queue.

    Long work gets there without high rates. A “download all invoices” export that runs for 30 seconds holds its slot for all 30. A 200-thread pool fills at 200 ÷ 30 ≈ 6.7 exports a second, easy for a GST-filing app in the last week of the month. If the exports are CPU-bound the cores give out sooner; either way, login has nowhere to run.

    Little’s law on a pod with 200 request threads. On a normal day browse and exports fit easily; at month-end, 7 exports a second that each hold a thread for 30 seconds need more threads than exist, and the overload spreads.Notice The cascade loop never involves a bug. A full pool makes a healthy pod look dead, and restarting it moves its exports onto pods that are already full.

    Memory: bytes × concurrency

    The same law sizes memory: bytes held = arrival rate × duration × bytes per request. An export that builds a 400 MB spreadsheet in memory over 30 seconds, started once every 3 seconds, keeps 0.33 × 30 = 10 in flight: 4 GB. On a pod with a 4 GB memory limit, the runtime’s own baseline tips it over, the process is killed, and every request on it fails, not just the exports.

    The event loop: a pool of one

    Node.js runs all your JavaScript on one thread, the event loop, plus a small worker pool for file system, DNS, zlib and some crypto calls. The Node.js guide puts it simply: Node is fast when the work for each client at any moment is small. While a callback runs, no other client gets a turn.

    A bulk-import endpoint that calls JSON.parse on a 50 MB catalogue is enough: the guide measured about 1.3 s to parse a 50 MB JSON string. For those 1.3 s the process serves nobody, not even the health check. zlib.deflateSync, crypto.pbkdf2Sync and other *Sync calls do the same, and Python’s asyncio servers share the shape: CPU work in a coroutine stalls every request on that process.

    One Node.js process during a bulk import. A 50 MB JSON.parse holds the event loop for about 1.3 seconds; every request that arrives meanwhile is accepted by the kernel but not read until the parse ends.Notice The health check fails without anything being broken. A probe with a 1-second timeout gives up while the loop is still parsing; a few imports in a row and the orchestrator restarts a pod whose only problem was its workload.

    Symptoms you’d see

    The signature is that cheap routes get slow in step with one expensive route, while total traffic barely moves.

    • Unrelated endpoints slow down when the heavy one is busy. In Azure’s load test, a cheap user-profile GET served about 500 requests a second alone and fell to about 150 once 100 users started posting to a CPU-heavy endpoint on the same servers.
    • Web-tier CPU spikes that track a route, not traffic. Requests per second are flat, CPU is at 100%, and the spikes line up with uploads, exports or the 9 am import window.
    • Request queue time climbs. Requests wait longer between the load balancer and your handler; thread pools sit at their maximum and the server’s HTTP queue grows.
    • 502s, 504s and client timeouts, mostly on routes that did nothing wrong.
    • Health checks fail under load, so the orchestrator restarts pods that weren’t broken and the survivors inherit their load, as in Figure 2. See health checks done right.
    • Autoscaling helps briefly. New pods get their share of heavy requests and fill up too; Azure’s guidance says plainly that scaling the front end is not the fix.
    • Event-loop lag in Node.js: timers fire hundreds of milliseconds late, and p99 jumps for every route at once.
    • Connections held for minutes, which stall deploys and get cut by load balancer idle timeouts, so the user’s retry starts the work again.
    • Out-of-memory kills that coincide with exports or large uploads, and dependencies throttling with 429s as the heavy route hammers storage or the database.

    Why it happens

    Nobody sets out to build this. It accumulates from reasonable shortcuts.

    The controller is the easiest place for code

    The upload arrives in a handler, and the image library is one import away. Resizing inline is ten lines and works perfectly on a laptop. A queue, a worker deployment and a status endpoint feel like ceremony for a minimum viable product.

    “It’s only for admins” and “it’s only a few a day”

    The bulk import is for internal ops, the PDF statement is a monthly button. Then a Flipkart-style sale starts, 5,000 sellers upload catalogues at 9 am, and “a few a day” is 20 a second.

    One process, no line between “must wait” and “can wait”

    Many frameworks deploy one process type for every route, and Azure notes the pattern is typical of monoliths where business logic shares a tier with the presentation layer. A user tapping “Publish menu” must learn that the photo was accepted, not wait for three resized variants; when nobody draws that line, everything lands on the synchronous path.

    Serverless doesn’t remove the wait

    Moving the heavy handler into a function behind an API gateway still keeps the client waiting on one HTTP call. Amazon API Gateway REST APIs have a 29-second default integration timeout and a 10 MB payload limit, while a Lambda function can run for up to 15 minutes. A 90-second report returns a 504 at 29 seconds, the function keeps going, and the user’s retry starts a second copy.

    Inputs grow

    A CSV import tested with 1,000 rows meets a seller with 400,000. The Node.js guide adds a sharper case: some regular expressions take exponential time on crafted input, so a malicious request can block the loop on purpose.

    A background thread is still the front end

    Azure’s example of this anti-pattern already “offloads”: the handler starts a new thread and returns 202 at once. The response is fast, but the work still burns the same pod’s cores, and now nothing limits how many run at once. Task.Run, thread-pool executors and fire-and-forget goroutines share the flaw. Moving work off the request thread isn’t moving it off the web tier.

    How to detect it

    You’re looking for one route whose cost lands on everyone else. Measure cost per route, then prove the link.

    Rank routes by CPU, not by latency

    Most APM tools and continuous profilers report CPU time per route. Rank by total CPU = calls per second × CPU per call, not by p99: a route with 0.5% of the traffic using 60% of web-tier CPU is your suspect even if its own latency looks fine. Note its duration and memory per call too.

    Measure queue time and pool saturation

    Time spent waiting for a slot never shows in your handler’s timer. Stamp the time in a header at the load balancer or proxy and subtract it when the handler starts; many APM agents call this request queue time. Graph it next to active threads or workers against the maximum. A busy front end pins the pool during heavy-route bursts.

    Correlate the heavy route with a cheap one

    Put a cheap route’s p99 and the heavy route’s rate on one panel. With OpenTelemetry HTTP metrics exported to Prometheus:

    # Panel 1: p99 of a cheap route
    histogram_quantile(0.99,
      sum by (le) (rate(http_server_request_duration_seconds_bucket{http_route="/login"}[5m])))
    
    # Panel 2, same time axis: requests per second to the heavy route
    sum(rate(http_server_request_duration_seconds_count{http_route="/menu-items/{id}/photo"}[5m]))

    If login’s p99 rises and falls with upload traffic while login traffic is flat, the two share a resource they shouldn’t.

    Watch the event loop

    In Node.js, measure how late the loop runs, not just CPU. monitorEventLoopDelay samples the delay into a histogram in nanoseconds, and eventLoopUtilization reports the share of time the loop was busy:

    import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
    
    const RES_MS = 10;
    const delay = monitorEventLoopDelay({ resolution: RES_MS }); // timer-based sampling
    delay.enable();
    let last = performance.eventLoopUtilization();
    
    setInterval(() => {
      const now = performance.eventLoopUtilization();
      const elu = performance.eventLoopUtilization(now, last); // busy share since last tick
      last = now;
      // each timer-based sample includes the 10 ms interval itself, so subtract it
      metrics.gauge('eventloop.lag.p99_ms', delay.percentile(99) / 1e6 - RES_MS);
      metrics.gauge('eventloop.lag.max_ms', delay.max / 1e6 - RES_MS);
      metrics.gauge('eventloop.utilization', elu.utilization);
      delay.reset();
    }, 10_000);

    A healthy API server shows a lag of a few milliseconds and utilisation well below 1. A max near 1,300 ms right after a bulk import is Figure 3 on your dashboard.

    Profile under load

    A CPU flame graph taken during the slowdown names the culprit: image codecs, PDF or headless-browser renderers, spreadsheet writers, zlib, JSON parsing or model runtimes, sitting under your request handlers. If the wide frames are socket reads or lock waits instead, it’s a different problem.

    Prove it with a step load test

    Azure’s method: run a steady load of the cheap route, add a stepped load of the suspect route, and watch the cheap route’s throughput and p99. Repeat without the suspect route. If the cheap route recovers, you’ve found it.

    Telling it apart from look-alikes

    Busy front end Synchronous I/O Busy database Noisy neighbour
    Web-tier CPU High, tracks one route Low or moderate Low: pods wait High or throttled; your workload unchanged
    Threads or event loop Busy computing; loop lag high Busy waiting; CPU idle Waiting on database calls Running slower than usual
    Database CPU Normal Normal Pinned Normal, unless the neighbour is there
    Flame graph Codec, PDF, zip, JSON or crypto frames Socket reads, sleeps, lock waits Driver waits; heavy statements in the DB Usual code; CPU steal or throttling rises
    Moves with Calls to one heavy route A slow dependency Heavy queries and reports Another tenant or job on the host
    First fix Queue and workers Async I/O, timeouts Move work out of the database Isolate, limit or move
    Busy front end or something else? Check where the CPU is and what threads are doing

    Where busy front end ends and synchronous I/O begins

    Both exhaust request slots, for opposite reasons. In synchronous I/O threads sit idle waiting on the network or disk, so CPU is low and async I/O is the fix. In a busy front end the threads are computing, so async I/O changes nothing: the work needs other machines. It is a noisy neighbour you wrote yourself, inside your own process.

    How to fix it

    The principle: the request path does only what the user must wait for. Everything else is accepted quickly, recorded durably, and done by a tier sized and scaled for that work.

    Before: the handler does the work

    # BEFORE: the web worker decodes and resizes a 12 MP photo itself.
    # storage, jobs and queue are thin wrappers over your object store, database and broker.
    from io import BytesIO
    from flask import Flask, request, jsonify
    from PIL import Image
    
    app = Flask(__name__)
    SIZES = {"thumb": 200, "card": 600, "full": 1600}
    
    def write_sizes(original, item_id):
        for name, width in SIZES.items():
            img = original.copy()
            img.thumbnail((width, width))              # resample: CPU-heavy
            buf = BytesIO()
            img.save(buf, "WEBP", quality=80)          # encode: CPU-heavy
            storage.put(f"menu/{item_id}/{name}.webp", buf.getvalue())
    
    @app.post("/menu-items/<item_id>/photo")
    def upload_photo(item_id):
        original = Image.open(request.files["photo"].stream)  # decodes ~36 MB of pixels
        write_sizes(original, item_id)                         # ~400 ms of CPU, here
        return jsonify(status="done"), 201

    After: validate, enqueue, answer 202

    This is the Queue-Based Load Leveling pattern: a queue sits between a bursty producer and a consumer that works at its own pace.

    # AFTER: the handler stores bytes and enqueues; it never decodes the image.
    MAX_BYTES = 20_000_000
    
    @app.post("/menu-items/<item_id>/photo")
    def upload_photo(item_id):
        photo = request.files["photo"]
        if (request.content_length or 0) > MAX_BYTES:
            return jsonify(error="photo must be under 20 MB"), 413
        if photo.mimetype not in {"image/jpeg", "image/png"}:
            return jsonify(error="send a JPEG or PNG"), 415
        key = request.headers.get("Idempotency-Key")
        job = jobs.find(current_user.id, key) if key else None
        if job is None:                                    # first time we see this upload
            job = jobs.create(current_user.id, key, item_id=item_id, status="queued")
            storage.put(f"originals/{job.id}", photo.stream)   # streams bytes, no decoding
            queue.send({"job_id": job.id})
        return "", 202, {"Location": f"/jobs/{job.id}", "Retry-After": "2"}
    
    @app.get("/jobs/<job_id>")
    def job_status(job_id):
        job = jobs.get(current_user.id, job_id)
        if job is None:
            return jsonify(error="no such job"), 404
        if job.status == "succeeded":
            return "", 303, {"Location": f"/menu-items/{job.item_id}/photo"}
        return jsonify(status=job.status, updated_at=job.updated_at.isoformat()), 200
    # worker.py: its own deployment, scaled on queue backlog, serves no HTTP.
    def handle(msg):
        job = jobs.get_by_id(msg["job_id"])
        if job.status == "succeeded":          # at-least-once delivery: skip duplicates
            return
        original = Image.open(BytesIO(storage.get(f"originals/{job.id}")))
        write_sizes(original, job.item_id)     # the same CPU work, on a worker
        jobs.update(job.id, status="succeeded")
    
    for msg in queue.receive(visibility_timeout=120):
        try:
            handle(msg.body)
            msg.ack()
        except Exception:
            if msg.delivery_count >= 5:        # poison message: park it, alert a human
                dlq.send(msg.body)
                msg.ack()
            # otherwise don't ack: the queue redelivers after the timeout

    The web pod now spends a millisecond or so of CPU per upload on validation and bookkeeping, plus I/O to stream the bytes. A burst of 2,000 uploads becomes a backlog that workers drain, not 2,000 requests fighting login for eight cores.

    You pay with eventual completion: the photo appears seconds later, so the UI needs a “processing” state. You run a queue and a worker deployment, workers must be idempotent because delivery is at-least-once, and messages that always fail need a dead-letter queue with an alarm. A crash between creating the job row and sending the message strands the job; a transactional outbox closes that gap. And debugging crosses a queue, so carry a request ID in every message.

    Tell the client where to look: asynchronous request-reply

    202 Accepted is HTTP for “got it, not done”. RFC 9110 calls it intentionally noncommittal: the work might still be refused, and the response should point to a status monitor. The Asynchronous Request-Reply pattern fills in the details:

    POST /reports/gst-summary HTTP/1.1
    Content-Type: application/json
    Idempotency-Key: 3f6c0e1a-7b2d-4c55-9a7e-2f1d8b6c4e90
    
    {"gstin": "29ABCDE1234F1Z5", "month": "2026-09"}
    
    HTTP/1.1 202 Accepted
    Location: /jobs/81f3
    Retry-After: 10
    
    GET /jobs/81f3 HTTP/1.1
    
    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {"status": "running", "percentComplete": 40}
    
    GET /jobs/81f3 HTTP/1.1
    
    HTTP/1.1 303 See Other
    Location: /reports/81f3.pdf
    • Validate before you accept: a bad request gets a 400 now, not a failed job later.
    • Location and Retry-After tell the client where to poll and how often.
    • The status resource answers 200 while work is pending, with a state such as queued, running or failed, then 303 See Other to the result. A 303 makes the client fetch with GET; a 302 doesn’t guarantee that, and a client that replays the POST duplicates the job.
    • Failures live at the status URL as structured errors; offer DELETE to cancel, and expire old status records.

    Polling isn’t the only way back: a server-to-server client can register a webhook, a browser can listen on a WebSocket or server-sent events, and a phone can get a push notification. Each removes polling load and adds a channel to run.

    Asynchronous request-reply for a GST summary report. The API answers 202 in milliseconds; the worker takes as long as it needs; the client polls the status URL and is redirected to the result.Notice No HTTP connection stays open while the report renders. The slowest step happens on a lifeline that serves no users.

    In the interview

    The sentence to say: “The upload endpoint only validates, stores the bytes and enqueues a job, then returns 202 with a status URL. Resizing runs on a separate worker tier scaled on queue backlog, so a burst of uploads becomes a longer queue, not a slower login.”

    Keep big uploads off the web tier: presigned URLs

    Even streaming bytes holds a web connection and buffers for as long as a slow phone takes to send 12 MB. Let the client upload straight to object storage instead; the API only signs a time-limited URL:

    import uuid
    
    @app.post("/menu-items/<item_id>/photo-upload-url")
    def photo_upload_url(item_id):
        key = f"originals/{item_id}/{uuid.uuid4()}.jpg"
        url = s3.generate_presigned_url(
            "put_object",
            Params={"Bucket": UPLOAD_BUCKET, "Key": key, "ContentType": "image/jpeg"},
            ExpiresIn=300,                     # valid for five minutes
        )
        return jsonify(upload_url=url, key=key), 200

    The phone sends a PUT to that URL, and a bucket notification puts a “new object” event on the queue that feeds the workers. The S3 guide covers the mechanics; Azure calls the general idea the valet key pattern.

    You pay with validation after the fact: the worker must check type, size and pixel count, because the client chose the bytes. Abandoned uploads leave orphan objects that need a lifecycle rule, and a leaked URL works until it expires, so keep expiry short. Large file uploads covers multipart and resumable uploads.

    Size the worker tier on backlog, not CPU

    Scale workers on backlog per worker against the delay you promise; autoscaling done right has the formula. The arrival rate gives the floor: 20 uploads a second × 0.4 s keeps 8 vCPUs busy, so at a 70% target you need 8 ÷ 0.7 ≈ 12 vCPUs of workers.

    Queues hide trouble while they grow, so watch message age, not just depth. Amazon’s Builders’ Library article on queue backlogs spells out the recovery arithmetic: after an hour-long outage you need double your normal capacity for another hour to catch up, and a spike that queues 10 times what consumers are scaled for, noticed after 30 minutes, takes 300 minutes to drain.

    • Separate queues by urgency. A seller watching a spinner for one thumbnail shouldn’t wait behind a bulk re-render of 2 million images; give interactive jobs their own queue and workers.
    • Bound consumer concurrency. Azure warns that autoscaling consumers without limiting their combined rate just moves the overload to the database or storage behind them.
    • Shed at the door when the backlog is hopeless. Return 503 with Retry-After rather than accept work you’ll finish too late; see backpressure and load shedding.

    Bulkhead what must stay synchronous

    Sometimes the user really must wait: a price quote from a model, a preview render. Give that route its own compartment: a separate deployment behind path-based routing, or at least its own small pool or semaphore. In Node.js, run the CPU work in worker_threads sized to the cores. The heavy route can then exhaust only its own capacity. You pay with reserved capacity and routing config, and those users still wait; see bulkheads.

    Functions for bursty jobs

    Event-driven serverless functions suit bursty, independent jobs: one invocation per uploaded object, scaling with the burst and idle at 3 am. Trigger them from the queue or bucket event, never from the user’s synchronous request. You pay with the 15-minute Lambda cap, cold starts, a per-invocation bill that overtakes always-on workers at steady volume, and a fleet that can scale faster than your database.

    Push it to the edge or the client

    Image CDNs resize and re-encode on request from URL parameters and cache each variant at the edge, and the app can shrink a 12 MP photo to 1,600 pixels before uploading. You pay a per-transformation bill, and you still validate everything the client sends.

    The same food-delivery platform before and after. Left: one web tier serves browse and login and also resizes photos, renders PDFs and parses imports. Right: the API only validates, signs and enqueues; photos go straight to object storage; workers scaled on backlog do the heavy work.Notice The API pods’ CPU no longer depends on how many photos arrive. A burst of uploads now grows a queue instead of slowing every other route.

    Compare the fixes

    Fix Moves the work to You gain You pay
    Queue and worker tier Workers scaled on backlog Cheap routes isolated; bursts become backlog Eventual completion; a queue to run; idempotent workers, a DLQ
    202 with a status URL Same workers; client polls or is notified Honest contract; no minutes-long connections Status store and retention; polling load; client changes
    Presigned upload and storage event Object storage, then workers Bytes never touch the web tier Validation after upload; orphan objects
    Bulkhead for synchronous heavy routes Its own deployment or pool Other routes protected, little redesign Users still wait; reserved capacity
    Serverless functions The provider’s fleet, per event Bursts absorbed; no idle cost 15-minute cap; cold starts; cost at steady volume
    Image CDN or client-side work The edge or the device No server CPU; cached variants Per-transform bills; client output needs checking
    Bigger web pods More cores, same tier No code change Heavy-route peaks paid on every pod; latency still tied to it
    Where each fix moves the heavy work, and what it costs

    Verify the fix

    Rerun the step load test: steady browse plus a rising upload rate. Login and browse p99 should stay flat, web CPU should track cheap traffic only, and the new curve to watch is queue age against your promise, say thumbnails within 10 seconds. If queue age rises while workers idle, look for a poison message or a throttled dependency.

    When it’s fine

    • Small, bounded CPU work that is the response. Serialising JSON, rendering a template, verifying a token or totalling a cart usually costs well under 10 ms. Cap input sizes so “small” stays small.
    • Work the user must wait for, with its own limits. Login must hash the password, deliberately slowly, and a UPI-style payment must verify signatures. Keep it synchronous but bounded: async APIs (Node’s crypto.scrypt runs on the worker pool; scryptSync blocks the loop), a concurrency limit, and capacity for the peak.
    • Low-traffic internal tools, such as an admin report run ten times a day on its own deployment.
    • Early products, with a tripwire. Inline resizing at 50 uploads a day is fine if you alert on per-route CPU and know where the queue will go.

    The test: at peak, the heavy route’s CPU, slots and memory fit in the pod’s headroom, and a load test of that route leaves cheap routes’ p99 flat.

    Cheat sheet

    Idea Remember it as
    Busy front end Heavy work on the request threads or event loop that cheap requests also need
    CPU budget Rate × CPU per call against vCPUs: 20 uploads/s × 0.4 s = 8, a whole 8-vCPU pod
    Slot budget Little’s law, slots = rate × duration: 200 threads ÷ 30 s ≈ 6.7 long requests/s
    Memory budget Rate × duration × bytes: 0.33/s × 30 s × 400 MB ≈ 4 GB
    Event loop One thread: a 1.3 s parse stalls every request in the process
    Fingerprint Cheap-route p99 moves with heavy-route rate; web CPU high, database normal
    Detect CPU per route, request queue time, pool use, event-loop lag, flame graphs, step load test
    Fix Validate → store → enqueue → 202 + Location; workers scaled on backlog; 303 to the result
    Costs Eventual completion, polling or push, idempotency, DLQs, more moving parts
    Fine when Under ~10 ms of CPU, bounded input, low traffic, or its own bounded pool

    How it shows up in interviews

    It hides in any prompt where users hand you something heavy: photo sharing (who makes the thumbnails?), YouTube-style upload and transcoding, Dropbox-style sync, UPI statement PDFs, seller catalogue imports during a sale, a notification system sending a million pushes, and LLM inference.

    The probes: “Where does the resizing happen?” “Five thousand sellers upload catalogues at 9 am; what happens to checkout latency?” “The report takes 90 seconds; what does the API return?” Answer with the budget arithmetic, then the queue, the 202 contract and how workers scale.

    How to say it in the interview

    “I keep the request path for what the user must wait for. Upload, report and import endpoints validate, store the input, enqueue a job and return 202 with a status URL, deduplicated by an idempotency key. A separate worker tier, scaled on queue backlog and age, does the CPU work; the status endpoint answers 303 to the result, or we push a notification. Photos go straight to object storage with presigned URLs. Otherwise 20 uploads a second at 400 ms of CPU is a whole 8-core pod, and login waits behind it. The price is eventual completion, idempotent workers for at-least-once delivery, a dead-letter queue, and more moving parts.”

    Why not just add more web servers? Each new pod gets its share of heavy requests and fills the same way, so you pay for heavy-route peaks on every pod and cheap-route latency still depends on the upload mix. Azure’s guidance says this shouldn’t be fixed by scaling the front end.

    Isn’t async or a background thread enough? No. Async frees a thread while it waits for I/O; it doesn’t make computation cheaper. A background thread returns the response sooner, but the CPU work still runs on the same pod, now with no limit on how many run at once.

    What if the user taps upload twice, or the worker crashes halfway? The idempotency key makes a repeat return the same job. The queue redelivers unacknowledged messages, so workers check job state and write results under deterministic keys; messages that keep failing go to a dead-letter queue with an alarm.

    How do you size the worker tier? From arrival rate × CPU per job: 20 uploads a second × 0.4 s keeps 8 vCPUs busy, so about 12 at a 70% target. Autoscale on backlog per worker against the promised delay, alarm on message age, and cap concurrency so workers can’t flood the database.

    Where to go next

    References and further reading

    1. Busy Front End antipattern (Azure Architecture Center)learn.microsoft.comWhy background threads in the web process still starve requests, the load test where a cheap GET fell from about 500 to 150 requests per second, and why scaling the front end is not the fix.
    2. Queue-Based Load Leveling pattern (Azure Architecture Center)learn.microsoft.comA queue as a buffer between bursty producers and a steady consumer, plus at-least-once delivery, dead-letter queues and bounding consumers so autoscaling doesn’t just move the overload.
    3. Asynchronous Request-Reply pattern (Azure Architecture Center)learn.microsoft.com202 Accepted with Location and Retry-After, a status endpoint that redirects with 303, idempotency keys, cancellation and retention of status resources.
    4. RFC 9110: HTTP Semantics, sections 15.3.3 (202 Accepted) and 15.4.4 (303 See Other)rfc-editor.org202 is deliberately noncommittal and should point to a status monitor; 303 sends the client to a different resource with a GET.
    5. Don’t Block the Event Loop (or the Worker Pool) (Node.js documentation)nodejs.orgOne event loop plus a small worker pool, synchronous APIs to avoid in servers, the 50 MB JSON example (about 1.3 s to parse), partitioning versus offloading.
    6. Node.js documentation: Performance measurement APIs (perf_hooks)nodejs.orgmonitorEventLoopDelay (a histogram in nanoseconds, sampled every 10 ms by default) and eventLoopUtilization.
    7. Avoiding insurmountable queue backlogs (Amazon Builders’ Library)aws.amazon.comWhy backlogs make queues bimodal, the recovery arithmetic after an outage, measuring message age, and separate or spillover queues per workload.
    8. Amazon S3 User Guide: Uploading objects with presigned URLsdocs.aws.amazon.comLetting a client upload straight to a bucket with a time-limited signed PUT, without AWS credentials.
    9. Amazon API Gateway quotas for REST APIsdocs.aws.amazon.comThe 29-second default integration timeout and the 10 MB payload limit.
    10. AWS Lambda Developer Guide: Configure function timeoutdocs.aws.amazon.comFunction timeout defaults to 3 seconds and can be raised to 900 seconds (15 minutes).