Skip to main content

Command Palette

Search for a command to run...

Continuations — Take a Buzzer, Free the Waiter | Async Apex

Updated
14 min read
P
Software Developer trying to balance work and passion

The async pattern that keeps the pipeline open, fires three callouts in parallel, and hands the response straight back to your LWC — without your component ever polling.

If you've been following the series, you've met the regular crew by now — @future, Batch, Schedulable, Queueable. They all share one personality trait: fire and forget. You kick them off, they wander away to do their thing, and you have no clean way of knowing when they'll be done. If your UI wants the result, it has to keep nagging — querying a custom object on a timer, refreshing, hoping.

Continuations are the odd one out. They're still asynchronous, but they keep the line open. The moment the external service answers, the response travels straight back to your component. No polling. No "store it somewhere and fetch it later." That single difference is the whole reason this pattern exists.

This is the bonus episode of the Async Apex series — Continuations don't quite fit the "process records in the background" mould of the others. They live almost entirely on the frontend side, built for one job: making long-running callouts from Lightning components feel snappy.


The problem they were born to solve

Salesforce is multi-tenant. You're sharing servers with thousands of other orgs, so the platform is ruthless about anyone hogging a thread. One of the rules that bites people:

Any single org can only have 10 synchronous transactions running longer than 5 seconds at any given moment. Hit an 11th, and it's denied.

Let me make that concrete.

Say all your support agents log in at sharp 9:00 AM to pull the tasks assigned to them for the day. You've got 15 of them. They all click the same button at 9:00. Behind that button is a callout to an external API — and the API is, let's politely say, not in a hurry this morning.

Five seconds tick by. Now you've got 15 synchronous transactions, all sitting around waiting on a slow callout, all counting against that limit of 10. Salesforce doesn't care that it's a trivial task. It throws the door shut:

ConcurrentRequestLimitExceeded

All those agents, blocked — not because the work was heavy, but because the thread was held open too long, too many times at once.

You can fix this with @future or Queueable. But then you've signed up for the polling tax: stash the response in a custom object, then have the component query that object again to actually show it. Two round trips and a bunch of plumbing for what should be one request.

Continuations sidestep the whole thing. When you return a Continuation, Salesforce literally frees the thread the moment the callout goes out. The transaction that started it dies right there. Later — 15 seconds, 30 seconds, whenever the API finally answers — Salesforce wakes back up, picks up where it left off via a callback, and pushes the response to your component. The thread wasn't held hostage the entire time.

🔔 The buzzer is coming — hold that thought.

A note on that 5-second limit

Your notes from older docs aren't wrong, but the platform has moved. In a later release, Salesforce excluded HTTP callout processing time from the long-running request limit. So a slow callout on its own no longer eats into your 10-concurrent budget the way it used to — that specific pain point is largely gone.

The other reasons to reach for Continuations still stand, though. Keep reading.


How it's different from the rest of the async family

Four things set Continuations apart from @future / Batch / Schedulable / Queueable:

1. The pipeline stays open. All the others are fire-and-forget — you genuinely can't say when they'll run. Continuations are async too, but the request pipeline is kept open so the response can land directly on the UI the instant it arrives. With the others, your component has to babysit: poll periodically and check "is it done yet?"

2. You get the actual payload, not a job receipt. @future returns nothing. Batch/Queueable/Schedulable, when they do hand something back, give you job info — an Id, a status — not the data your component can render. You'd still have to query for the real result. A Continuation fetches the response in the shape your component needs and returns it directly.

3. Three callouts, in parallel. A single Continuation can fire up to 3 callouts side by side. Hand three callouts to the other async methods and they go out in series, one after another. Continuations run them concurrently — three slow APIs finish in roughly the time of the slowest one, not the sum of all three.

4. It bypasses the thread-hogging problem. As covered above — the thread is freed instead of held, which is exactly what keeps you clear of ConcurrentRequestLimitExceeded.

One more spec worth knowing: a Continuation callout can run up to a 120-second timeout, versus the 10 seconds you get on a standard synchronous callout. These are built for genuinely slow services.


The buzzer analogy (this is the whole mental model)

Here's the picture I keep in my head, and it makes every line of the code below obvious.

You walk into a busy diner and order food. The waiter takes your order and immediately leaves — they don't stand at your table for 20 minutes waiting on the kitchen. Instead, they hand you a buzzer. You're free, they're free, the kitchen does its thing.

When your food is ready, a different staff member glances at the buzzer, matches it to the right order, and brings you exactly what you asked for.

Map that onto Apex:

  • The waiter = the method that fires the callout. It leaves the instant the order is placed (the thread dies).

  • The buzzer = con.state. It's how the order gets matched back to you when the food's ready.

  • The kitchen = the external API.

  • The staffer who brings the food = your callback method. A fresh process wakes up, reads the buzzer, and delivers the response.

Every step that follows is just setting up that buzzer hand-off.


Building one, step by step

First, the boring-but-mandatory bit: just like any callout, you have to whitelist the URL. Go to Setup → Remote Site Settings and add your endpoint as a new remote site. Skip this and you'll get an "unauthorized endpoint" slap before anything async even happens.

Now the Apex.

1. Create the Continuation object. The argument is the timeout in seconds.

@AuraEnabled(continuation=true cacheable=true)
public static Object startRequest() {
    // Argument is the timeout, in seconds (max 120)
    Continuation con = new Continuation(40);

    // more to come here

    return con;
}

2. Name the callback. Apex kills the method that fired the callout the moment it goes out — so you have to tell it which method to come back to. This is your "staffer who brings the food."

con.continuationMethod = 'processResponse';

3. Build the HttpRequest like any other callout and attach it to the Continuation.

HttpRequest req = new HttpRequest();
req.setMethod('GET');
req.setEndpoint(LONG_RUNNING_SERVICE_URL);

con.addHttpRequest(req);

4. Set the state — the buzzer. This is the token that lets the callout get matched back to its handler when the response shows up.

con.state = 'Hello, World!';

5. Write the callback logic. The signature is fixed:

public static Object processResponse(List<String> labels, Object state) {
    // ...
}

labels can hold at most three entries — because a Continuation only allows 3 parallel callouts. state is the buzzer you set earlier, handed right back to you so you know which order this is.

6. Pull the response and do something useful with it.

HttpResponse response = Continuation.getResponse(labels[0]);

That's the entire skeleton. Now let's build something real with it.


Full example: three cities, three callouts, one click

Scenario: a little Lightning Web Component that fetches the current weather for three cities at once using a Continuation. Three parallel callouts, one button, results land on the card together.

The markup — weather.html

<template>
    <lightning-card title="Apex Continuation: 3-Way Parallel Callout" icon-name="custom:custom38">
        <div class="slds-var-m-around_medium">

            <template if:true={isLoading}>
                <lightning-spinner alternative-text="Waiting for Continuation framework..."></lightning-spinner>
            </template>

            <div class="slds-grid slds-gutters slds-var-m-bottom_medium">
                <div class="slds-col">
                    <lightning-input label="City 1" value={city1} onchange={handleCity1Change}></lightning-input>
                </div>
                <div class="slds-col">
                    <lightning-input label="City 2" value={city2} onchange={handleCity2Change}></lightning-input>
                </div>
                <div class="slds-col">
                    <lightning-input label="City 3" value={city3} onchange={handleCity3Change}></lightning-input>
                </div>
            </div>

            <div class="slds-var-m-bottom_medium">
                <lightning-button
                    label="Fetch All 3 Weather Reports Asynchronously"
                    variant="brand"
                    onclick={handleFetchWeather}>
                </lightning-button>
            </div>

            <hr/>

            <div class="slds-grid slds-gutters slds-var-m-top_medium">
                <div class="slds-col slds-box slds-theme_shade slds-var-m-horizontal_small">
                    <p class="slds-text-title_bold">{city1}</p>
                    <p class="slds-text-heading_medium">{city1Result}</p>
                </div>
                <div class="slds-col slds-box slds-theme_shade slds-var-m-horizontal_small">
                    <p class="slds-text-title_bold">{city2}</p>
                    <p class="slds-text-heading_medium">{city2Result}</p>
                </div>
                <div class="slds-col slds-box slds-theme_shade slds-var-m-horizontal_small">
                    <p class="slds-text-title_bold">{city3}</p>
                    <p class="slds-text-heading_medium">{city3Result}</p>
                </div>
            </div>

        </div>
    </lightning-card>
</template>

The controller — weather.js

Here's the single most important line in the entire article. Look at the import:

import fetchTriCityWeather from '@salesforce/apexContinuation/WeatherContinuationController.fetchTriCityWeather';

Not @salesforce/apex/. It's @salesforce/apexContinuation/. This is the LWC-specific distinction that trips everyone up — the module that tells the framework "this Apex method returns a Continuation, keep the pipeline open for it." Get this wrong and nothing works; get it right and the rest is just a normal imperative Apex call.

import { LightningElement, track } from 'lwc';
import fetchTriCityWeather from '@salesforce/apexContinuation/WeatherContinuationController.fetchTriCityWeather';

export default class Weather extends LightningElement {
    // Default values to make testing fast out of the box
    @track city1 = 'Hyderabad';
    @track city2 = 'Pune';
    @track city3 = 'Bangalore';
    @track city1Result = 'No Data';
    @track city2Result = 'No Data';
    @track city3Result = 'No Data';
    isLoading = false;

    handleCity1Change(event) { this.city1 = event.target.value; }
    handleCity2Change(event) { this.city2 = event.target.value; }
    handleCity3Change(event) { this.city3 = event.target.value; }

    async handleFetchWeather() {
        this.isLoading = true;

        // A random tracking token to ride across the async bridge — our buzzer
        const trackingToken = 'Token-' + Math.floor(1000 + Math.random() * 9000);

        try {
            // Call the continuation method, passing the user inputs
            const result = await fetchTriCityWeather({
                cityA: this.city1,
                cityB: this.city2,
                cityC: this.city3,
                sessionToken: trackingToken
            });

            // Parse each independently returned payload
            if (result.resA) {
                const dataA = JSON.parse(result.resA);
                this.city1Result = dataA.current?.temp_c != null ? `${dataA.current.temp_c}°C` : 'Error/No Data';
            }
            if (result.resB) {
                const dataB = JSON.parse(result.resB);
                this.city2Result = dataB.current?.temp_c != null ? `${dataB.current.temp_c}°C` : 'Error/No Data';
            }
            if (result.resC) {
                const dataC = JSON.parse(result.resC);
                this.city3Result = dataC.current?.temp_c != null ? `${dataC.current.temp_c}°C` : 'Error/No Data';
            }

            console.log('Session tracking completed with token:', result.passedState);
        } catch (error) {
            console.error('Framework boundary error occurred:', error);
            this.city1Result = 'Failed';
            this.city2Result = 'Failed';
            this.city3Result = 'Failed';
        } finally {
            this.isLoading = false;
        }
    }
}

Notice how clean the JS is. We await the Apex method and just get the data back — no polling loop, no second query. The apexContinuation import does the heavy lifting of keeping the line open.

The meta — weather.js-meta.xml

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>65.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>

The backend — WeatherContinuationController.cls

This is where the buzzer pattern comes fully to life. Watch how the three requests get queued and how state rides along.

public with sharing class WeatherContinuationController {

    private static final String BASE_URL = 'https://api.weatherapi.com/v1/current.json';
    // Replace with your own free WeatherAPI.com token
    private static final String API_KEY = 'YOUR_API_KEY_HERE';

    @AuraEnabled(continuation=true cacheable=true)
    public static Object fetchTriCityWeather(String cityA, String cityB, String cityC, String sessionToken) {

        // 1. Instantiate the Continuation with a 40-second timeout
        Continuation con = new Continuation(40);

        // 2. Map the callback Salesforce will wake up later
        con.continuationMethod = 'processTriCityResponse';

        // 3. Stash our tracking token into the state container — the buzzer
        con.state = sessionToken;

        // 4. Request for City 1
        HttpRequest req1 = new HttpRequest();
        req1.setMethod('GET');
        req1.setEndpoint(BASE_URL + '?key=' + API_KEY + '&q=' + EncodingUtil.urlEncode(cityA, 'UTF-8'));

        // 5. Request for City 2
        HttpRequest req2 = new HttpRequest();
        req2.setMethod('GET');
        req2.setEndpoint(BASE_URL + '?key=' + API_KEY + '&q=' + EncodingUtil.urlEncode(cityB, 'UTF-8'));

        // 6. Request for City 3
        HttpRequest req3 = new HttpRequest();
        req3.setMethod('GET');
        req3.setEndpoint(BASE_URL + '?key=' + API_KEY + '&q=' + EncodingUtil.urlEncode(cityC, 'UTF-8'));

        // 7. Queue all 3 parallel requests into the framework engine
        con.addHttpRequest(req1);
        con.addHttpRequest(req2);
        con.addHttpRequest(req3);

        // 8. Hand control to Salesforce. This thread dies right here.
        return con;
    }

    @AuraEnabled(cacheable=true)
    public static Map<String, String> processTriCityResponse(List<String> labels, Object state) {
        Map<String, String> results = new Map<String, String>();

        // 9. Collect the payloads using the sequential array of labels
        HttpResponse responseA = Continuation.getResponse(labels[0]);
        HttpResponse responseB = Continuation.getResponse(labels[1]);
        HttpResponse responseC = Continuation.getResponse(labels[2]);

        // 10. Package everything into a map to pass back across the JS wire
        results.put('resA', responseA.getBody());
        results.put('resB', responseB.getBody());
        results.put('resC', responseC.getBody());
        results.put('passedState', (String) state);

        return results;
    }
}

Trace the flow once and it clicks:

  • fetchTriCityWeather is the waiter. It places three orders, hands over the buzzer (con.state = sessionToken), and walks away at return con; — thread gone.

  • Salesforce holds nothing. When all three APIs answer, it wakes a fresh process and calls processTriCityResponse — the staffer with the food.

  • labels arrives in the same order you queued the requests, so labels[0] is City 1, labels[1] City 2, labels[2] City 3.

  • state is your buzzer, handed back untouched, so you always know whose order this was.

Output :


The testing gotcha nobody warns you about

Salesforce gives you two methods to test Continuations:

  • Test.setContinuationResponse(label, fakeResponse) — set a mock response, so no real callout fires in test context.

  • Test.invokeContinuationMethod(controller, continuation) — forces the runtime to run your callback against that mock.

Call setContinuationResponse before invokeContinuationMethod. In a test, the callout is never actually sent — the framework just runs your callback synchronously against the fake response.

Here's the trap. Both invokeContinuationMethod and the original Visualforce-era design expect a controller instance. Continuations were born in the Visualforce world, where controllers are instances with non-static methods. But LWC and Aura require @AuraEnabled methods to be static.

⛔ You cannot directly test an Apex Continuation that lives in a static context (i.e. the LWC/Aura pattern) the textbook way — there's no instance to hand invokeContinuationMethod, and in test context the async machinery runs synchronously without storing a Continuation reference.

The community workaround: lean on @TestVisible member variables to capture the bits your test needs, and exercise the callback method directly rather than going through invokeContinuationMethod. It's not an official Salesforce solution — it's the pragmatic one. So if your coverage strategy assumes the documented Visualforce test pattern will "just work" for your LWC controller, budget time for this. It won't, and now you know why.


When should you actually reach for one?

Continuations are a specialist tool, not a default. Pull them out when all of these are true:

  • You're calling an external service from a Lightning component and the user is waiting on that result.

  • The service is slow (think several seconds, up to the 120s ceiling).

  • You'd genuinely benefit from the response coming straight back to the UI without a polling/storage detour.

  • Bonus points if you can fan out into 2–3 parallel callouts and collapse their total wait into one.

If it's backend bulk processing, you want Batch or Queueable — go back and re-read those parts. If it's a quick callout that returns in under a second, a plain imperative Apex call is simpler. But for that "slow external API behind a button that users are staring at" case? Hand them a buzzer and free the waiter.


That wraps the Async Apex series — @future, Batch, Schedulable, Queueable, and now Continuations as the bonus round. If this mental model helped, the buzzer is yours to keep. 🔔

Async Apex — From the Ground Up

Part 6 of 6

A series revisiting Asynchronous Apex from first principles. Covers the why behind Governor Limits, and walks through all four async tools — Future Methods, Batch Apex, Queueable Apex, and Scheduled Apex — with real examples, gotchas, and the things most tutorials skip. Written by a developer, for developers who want to actually understand what's happening under the hood.

Start from the beginning

Asynchronous Apex: The 2AM Laundry Rule

Why Salesforce limits your code, how multi-tenancy works, and what happens when you go async — with an analogy that actually sticks.