MC SSJS gotchas: what actually fires in production
Server-Side JavaScript in Marketing Cloud is JavaScript the way it was in 2010 — SpiderMonkey 1.7, no modern syntax, single-threaded, with a Salesforce-specific Platform API on top. Ten gotchas Cleon hit at production scale, with the patterns that survive.
SSJS in Marketing Cloud reads like JavaScript, looks like JavaScript, and breaks like JavaScript from a parallel universe where ES6 never happened. The runtime is SpiderMonkey 1.7 (released 2007). The standard library is the bare minimum. The Salesforce-specific Platform.* API is what you actually use, and it has its own rules for when it works and when it pretends to work.
The list below is the set of things that bit us hard in production. Each one is paired with the pattern that keeps the script from emitting "Internal server error" at 2am.
The gotchas
1. SpiderMonkey 1.7. No modern JavaScript.
You don't get let, const, arrow functions, template literals, destructuring, spread, Object.assign, native Promise, async/await, optional chaining, or the array methods that landed after ES5. If your editor is auto-completing modern syntax, the editor is lying to you about what runs.
// THIS DOES NOT WORK
const subscriberKey = '12345'; // SyntaxError
const greet = (name) => `Hello, ${name}!`; // SyntaxError × 2
const { firstName, lastName } = subscriber; // SyntaxError
const merged = { ...defaults, ...overrides }; // SyntaxError
// THIS WORKS
var subscriberKey = '12345';
function greet(name) {
return 'Hello, ' + name + '!';
}
var firstName = subscriber.firstName;
var lastName = subscriber.lastName;
var merged = {};
for (var k in defaults) merged[k] = defaults[k];
for (var k in overrides) merged[k] = overrides[k];Write SSJS the way you'd write JavaScript before jQuery existed. var, function, traditional loops, manual object work. The cost is verbosity; the payoff is code that runs.
2. Platform.Load("Core","1.1.5") is mandatory at the top of every script.
Without it, the entire Platform.* namespace doesn't exist — and the error message you get says nothing about the missing load call. You'll see Platform is not defined or, worse, Platform.Function.RetrieveSalesforceObjects is not a function and waste an hour searching the wrong code path.
<script runat="server">
Platform.Load("Core", "1.1.5");
// Now Platform.Function.* and Variable.* and the rest exist
var rows = Platform.Function.LookupRows("master_subscribers", "SubscriberKey", "12345");
</script>The 1.1.5 version is the standard across SFMC tenants. Some legacy code uses Core without a version (Platform.Load("Core")); this still works but pins to whatever the tenant defaults to, which has changed across editions. Always specify the version.
3. The 30-minute hard timeout is the same as SQL Activities.
A Cloud Page or a SSJS Script Activity that runs longer than 30 minutes is killed. Long callouts, large DE iterations with LookupRows, or unbounded loops eat the budget without warning. There is no notification at 25 minutes; the script either finishes or vanishes.
4. Platform.Function.UpsertData is upsert in name only.
The SSJS upsert function looks like a transaction-safe write: row exists by primary key → update; row doesn't → insert. In practice it has two failure shapes that don't surface as errors:
- Wrong or missing PK on the destination DE → it inserts duplicates instead of updating.
- Type mismatch on a non-key column → silent truncation or coercion (same as the SQL length truncation gotcha — #5).
// AT RISK — assumes destination DE has SubscriberKey as PK; if it doesn't,
// you'll just keep adding rows
Platform.Function.UpsertData(
"master_subscribers",
["SubscriberKey"], [subKey],
["EmailAddress","Status"], [email, "Active"]
);
// DEFENSIVE — verify destination's PK in the run-log Data Extension
// before running the upsert in productionThe defense is the same as in SQL: verify the destination DE's primary key configuration before any "upsert" code reaches production. The function won't tell you the configuration is wrong — it'll just quietly do the wrong thing.
5. Platform.Function.LookupRows returns at most 2500 rows.
The signature suggests "give me all rows matching this filter." The reality is the function caps at 2500. Callers that need more rows get the first 2500 silently and miss the rest, which usually surfaces weeks later as a metric that's lower than it should be.
// AT RISK — assumes LookupRows returns everything that matches
var rows = Platform.Function.LookupRows("master_subscribers", "Status", "Active");
// If there are 100k Active subscribers, you get the first 2500.
// DEFENSIVE — explicitly cap and document the limitation
var rows = Platform.Function.LookupRows("master_subscribers", "Status", "Active");
if (rows.length === 2500) {
// We hit the cap. Either iterate with a page-key approach or
// pivot to a SQL Query Activity that can handle the volume.
Write("LIMIT-HIT: LookupRows returned 2500 — likely truncated");
}For real "all rows" semantics, drop down to WSProxy with RetrieveAsync (still paged, but you control the page size and can iterate) or pivot to a SQL Query Activity that's designed for bulk reads.
6. console.log doesn't exist. Output goes through Write().
There is no console. Debug output in a CloudPage script renders to the page itself via Write(). In an Automation Studio Script Activity, output gets captured to the Activity's run log only when you also call the right wrapper.
// In a CloudPage SSJS block — outputs to the rendered HTML
Write("Got " + rows.length + " rows");
// In an Automation Studio Script Activity — write to a log DE
Platform.Function.UpsertData(
"de_log_ssjs_runs",
["RunId"], [runId],
["Step","Message","Ts"], ["lookup", "got " + rows.length + " rows", Now()]
);The Cleon pattern: every production SSJS writes to a de_log_ssjs_runs DE at every step. The DE has columns RunId, Step, Message, Ts. When something looks off, you query the log instead of trying to reconstruct what happened.
7. Try/catch swallows errors silently unless you log inside the catch.
A bare try { ... } catch (e) {} succeeds even when the body fails. The Activity reports success because the catch doesn't re-throw. Days later you wonder why a value never wrote — the answer is in a catch block that never logged.
// AT RISK — error is swallowed, the Activity logs a successful run
try {
Platform.Function.UpsertData("master_subs",
["SubscriberKey"], [key],
["Status","UpdatedAt"], [status, Now()]);
} catch (e) {
// empty — silent failure
}
// DURABLE — silent only after logging
try {
Platform.Function.UpsertData("master_subs",
["SubscriberKey"], [key],
["Status","UpdatedAt"], [status, Now()]);
} catch (e) {
Platform.Function.UpsertData("de_log_errors",
["RunId","Step","Msg","Ts"],
[runId, "upsert-master", e.message, Now()]);
}Same principle as the SQL errors-should-not-pass-silently rule — silent only after logging the reason.
8. WSProxy auth tokens expire every ~20 minutes.
Long-running SSJS scripts that use WSProxy for SOAP API access need to handle token expiration. The first call works; the call 25 minutes in returns an auth error you didn't see during testing because your test took 30 seconds.
// AT RISK — first calls work, calls past the 20-min mark fail
var prox = new Script.Util.WSProxy();
for (var i = 0; i < bigList.length; i++) {
prox.retrieve("Subscriber", ["EmailAddress"], filter); // dies at row N
}
// DURABLE — re-instantiate periodically to get a fresh token
var prox = new Script.Util.WSProxy();
var lastRefresh = Now();
for (var i = 0; i < bigList.length; i++) {
if (DateDiff(lastRefresh, Now(), "Minute") > 15) {
prox = new Script.Util.WSProxy();
lastRefresh = Now();
}
prox.retrieve("Subscriber", ["EmailAddress"], filter);
}Refresh every 15 minutes (5-min safety margin under the 20-min expiry).
9. Platform.Request callouts have a 60-second per-request timeout.
External HTTP calls from SSJS via HTTP.Get / HTTP.Post time out at 60 seconds per call. Slow third-party APIs (CRM bulk export endpoints, anything synchronous against another tenant) blow this regularly.
// AT RISK — single call to a slow API, 60s timeout
var resp = HTTP.Get("https://slow-api.example.com/big-export?since=2026-01-01");
// DURABLE — paginate against the API, each page under the timeout
var page = 0;
var hasMore = true;
while (hasMore && page < 100) { // upper bound to prevent runaway
var resp = HTTP.Get("https://api.example.com/export?page=" + page + "&size=500");
// process resp...
page++;
// hasMore = ... derived from response
}Plus: external HTTP calls compound with the 30-minute Activity timeout. Ten 60-second calls eat 1/3 of your total budget.
10. SSJS is single-threaded and synchronous. No promises, no setTimeout.
There is no async. Every line waits for the previous line to complete. There is no Promise, no await, no event loop, no concurrency primitive. A long-running operation in line 5 blocks lines 6 through 200.
// This is the entire concurrency model: do one thing, then the next.
var rows = Platform.Function.LookupRows("master_subs", "Status", "Active");
for (var i = 0; i < rows.length; i++) {
// Each iteration waits. There is no Promise.all, no parallel iteration.
Platform.Function.UpsertData(
"de_log_processed",
["SubscriberKey"], [rows[i].SubscriberKey],
["ProcessedAt"], [Now()]
);
}If you need concurrency, the pattern is to split the work across multiple Automations or Script Activities that run in parallel at the Automation Studio scheduling layer — not inside a single SSJS.
Closing
These ten are the failure shapes that ate the most production hours. Cleon writes SSJS knowing every one of them can fire on the next run, and the scripts we ship are the ones that survive that assumption — modest in syntax, instrumented at every step, defensive about every external call.
If you spot a gotcha that bit your team and isn't here — write to hello@wearecleon.com. We add it, with credit.