Skip to main content

Marketing Cloud SSJS: Style Guide

The opinionated rules Cleon applies to every Server-Side JavaScript block we ship in Marketing Cloud — naming, formatting, commenting, patterns to prefer, anti-patterns to refuse — distilled from the gotchas and reference pages into a single discipline document.

Decision framework·Last updated 2026-05-13·Drafted by Lira · Edited by German Medina

This is the page where Cleon stops describing what MC SSJS is and starts saying what we do with it. Salesforce defines what works. The reference pages document the Platform.* surface. The gotchas document what fires at scale. This Style Guide is the discipline that keeps a CloudPage or a Script Activity maintainable a year after we hand it off.

Use it as a checklist before merging any new SSJS block into production. The rules are short on purpose — when a rule needs an explanation, the explanation is in the page it links to.

Naming

Code Resources, CloudPages, Script Activities follow a prefix convention

Decided once, before the first asset is created. Renaming them later means hunting through Automations, Journeys, and URL references.

| Prefix | Holds | |---|---| | CR_ | Code Resource (reusable SSJS block, included via Platform.Load) | | CP_ | CloudPage (publicly-rendered or auth-gated page) | | SA_ | Script Activity (Automation step) | | Auto_ | Automation that contains Script Activities | | J_ | Journey that calls into SSJS | | de_log_ssjs_ | Run log DE — append-only, indexed by RunId + Ts | | de_log_errors_ | Error log DE — written from catch blocks | | de_lookup_ssjs_ | Config DE that scripts read at runtime (Active, Mode, etc.) |

The pattern: prefix communicates what kind of asset, the rest of the name communicates purpose. SA_NightlyEnrichment reads in one pass.

RunId and Step are first-class instrumentation, not afterthoughts

Every production SSJS opens with:

Platform.Load("Core", "1.1.5");
var RUN_ID = Platform.Function.GUID();

function log(step, message) {
  Platform.Function.UpsertData(
    "de_log_ssjs_runs",
    ["RunId","Step","Ts"], [RUN_ID, step, Now()],
    ["Message"], [message]
  );
}

log("init", "started");

The RunId ties every row of every log together for a single execution. The Step label tells you where the script was. Without those two columns, your log DE is noise. See gotchas — #6.

Variable names carry intent

A variable called subscriberKey carries the decision; a variable called k carries nothing. Always name variables after what they hold, not after the loop index they live inside.

// AVOID
for (var i = 0; i < r.length; i++) {
  var k = r[i].k;
  // ...
}

// PREFER
for (var i = 0; i < rows.length; i++) {
  var subscriberKey = rows[i].SubscriberKey;
  // ...
}

i is fine as a numeric index. r and k are not, even with a nearby comment.

Formatting

Platform.Load("Core", "1.1.5") is line 1, always

Without it the Platform.* namespace doesn't exist. The error you get says nothing about the missing load call — you'll waste an hour looking at the wrong code path. See gotchas — #2 and Basics.

<script runat="server">
Platform.Load("Core", "1.1.5");
// everything else
</script>

Pin the version (1.1.5). Bare Platform.Load("Core") works but binds to tenant defaults that have moved across editions.

2-space indent, never tabs

Same reason as SQL: tabs render differently across the SSJS UI editor, GitHub diffs, and our docs. Two spaces is the convention.

Semicolons mandatory, one statement per line

SpiderMonkey 1.7's automatic semicolon insertion is unreliable when scripts are concatenated through Platform.Load into a single block. Always terminate statements. Always one statement per line.

// AT RISK — ASI may merge across lines
var a = lookup()
var b = lookup()

// DURABLE
var a = lookup();
var b = lookup();

var declarations grouped at the top of their scope

SpiderMonkey 1.7 hoists var to the top of the enclosing function or script block; placing them at the top matches the runtime model and prevents accidental reading-order confusion.

function processBatch(batch) {
  var rows = [];
  var errors = 0;
  var i, row, result;

  for (i = 0; i < batch.length; i++) {
    row = batch[i];
    // ...
  }
}

Magic strings extracted to constants at the top of the script

DE names, column names, status values — pull them out so a rename is one change instead of fifteen.

// AVOID
var rows = Platform.Function.LookupRows("master_subscribers", "Status", "Active");
Platform.Function.UpsertData("master_subscribers",
  ["SubscriberKey"], [k], ["Status"], ["Inactive"]);

// PREFER
var DE_SUBSCRIBERS  = "master_subscribers";
var STATUS_ACTIVE   = "Active";
var STATUS_INACTIVE = "Inactive";

var rows = Platform.Function.LookupRows(DE_SUBSCRIBERS, "Status", STATUS_ACTIVE);
Platform.Function.UpsertData(DE_SUBSCRIBERS,
  ["SubscriberKey"], [k], ["Status"], [STATUS_INACTIVE]);

Commenting

Don't comment what the code does — comment why

The code says what. Comments add the context the code can't carry.

// POINTLESS — repeats what the code already says
// Get active subscribers
var rows = Platform.Function.LookupRows("master_subs", "Status", "Active");

// USEFUL — explains the decision behind the cap
// Capped at 2500 by design; we expect <500 active accounts at this
// step in the Journey. If this ever returns 2500, alert and pivot
// to SQL — see SSJS gotchas #5.
var rows = Platform.Function.LookupRows("master_subs", "Status", "Active");
if (rows.length === 2500) { log("limit-hit", "LookupRows truncated"); }

Comment the receipt for non-obvious choices

When you cut a corner, comment it with the date and the reason. The next dev (often you, six months later) needs the receipt.

// TEMPORARY — replace by 2026-06-15 (calendar reminder set 2026-05-15)
// Reason: WSProxy retrieve fails the SSL handshake for our SOAP
// namespace until the MC Connect upgrade lands; LookupRows is the
// workaround while we wait.
var rows = Platform.Function.LookupRows(DE_SUBSCRIBERS, "Status", STATUS_ACTIVE);

See Marketing Cloud principles — #11 for the temporary-code discipline.

Patterns to prefer

Platform.Load("Core", "1.1.5") then everything else

Without it nothing in Platform.* resolves. See Basics and Platform.Function.

One RunId per execution, logged at every meaningful step

Generate the GUID once at script start, propagate it through every de_log_ssjs_runs write. The log becomes one query away from a full execution timeline. See gotchas — #6.

try { ... } catch (e) { log(e) } — never bare catch

A bare catch swallows the error and the Activity reports success. Catch and write e.message to de_log_errors_* before deciding whether to re-throw or continue. See gotchas — #7.

Re-instantiate WSProxy every 15 minutes in long-running scripts

WSProxy auth tokens expire at ~20 minutes. Reset the prox every 15 to stay clear of the boundary. See WSProxy and gotchas — #8.

Platform.Function.ParseJSON over JSON.parse

Older SFMC tenants don't expose native JSON.parse / JSON.stringify in SSJS. Platform.Function.ParseJSON works everywhere; JSON.parse may silently be undefined. See Platform.Function.

Explicit column lists in every UpsertData / InsertData / UpdateData

Same principle as SELECT * in SQL: implicit or copy-pasted column lists let a destination schema change silently re-shape your writes. Always pass the full column list, sourced from a constant. See Platform.Function and SQL Style Guide.

Stage, validate, then promote

For any non-trivial transformation, split into:

  1. Read source → write to de_stg_*
  2. Verification step: row count, sanity check, alert if outside the expected range
  3. Promote de_stg_* → production DE

Three steps, three checkpoints, recoverable. Same shape as the SQL Style Guide.

Use a SQL Query Activity, not LookupRows, for bulk reads

LookupRows caps at 2500. Anything that needs more rows is either a WSProxy retrieve loop or — usually better — a SQL Query Activity into a staging DE that the script then reads with bounded LookupRows. See gotchas — #5.

Parallel work goes across Script Activities, not inside one

SSJS is single-threaded synchronous. There is no Promise.all. If you need concurrency, fan out at the Automation Studio layer: multiple Script Activities scheduled in parallel, each handling a slice. See gotchas — #10.

Upper-bounded loops on every paginated callout

Any while (hasMore) { ... } against an external API gets a hard && iterations < N break. The 60-second per-call timeout combined with the 30-minute Activity timeout means a runaway loop eats the entire budget. See gotchas — #9.

Instrument runtime in every long-running Script Activity

Capture Now() at the top and at each milestone; write the deltas to de_log_ssjs_runs. When you eventually hit the 30-min timeout, you'll know which step grew. See gotchas — #3.

Patterns to refuse

let, const, arrow functions, template literals, destructuring, spread

SpiderMonkey 1.7 doesn't parse any of them. Your editor's autocomplete is lying to you about what runs. Use var, function, traditional string concatenation. See Basics and gotchas — #1.

Bare try { ... } catch (e) {}

Silent failure that reports as success. Always log inside the catch — write to de_log_errors_* with RunId, Step, e.message, Ts. See gotchas — #7.

LookupRows as an "all rows" iterator

Silently caps at 2500. Either explicitly assert the cap and alert when it fires, or pivot to a SQL Query Activity / WSProxy. See gotchas — #5.

console.log

There is no console. Use Write() for CloudPages or DE log writes for Script Activities. See gotchas — #6.

Skipping Platform.Load

The entire Platform.* namespace is undefined without it. Every block — even short snippets — starts with the load call. See gotchas — #2.

Promise, async, await, setTimeout

None of them exist in SpiderMonkey 1.7. Code that uses them is a SyntaxError (or a silent undefined if the parser is forgiving). For concurrency, split work across Script Activities orchestrated by Automation Studio. See gotchas — #10.

Unbounded while / for loops against external APIs

while (hasMore) without && iterations < N is a runaway waiting to happen. Always cap. See gotchas — #9.

UpsertData against a DE whose primary key you haven't verified

The function silently inserts duplicates instead of updating when the destination PK is wrong or missing. Verify destination PK before the upsert reaches production. See gotchas — #4.

JSON.parse on input you don't control without a try/catch

A malformed payload throws and — if uncaught — fails the Activity step silently. Wrap it, log the failure, fall back to a safe default. Prefer Platform.Function.ParseJSON either way (legacy tenant safety). See Platform.Function.

== / != against mixed types

SpiderMonkey 1.7 has the full ES5 type-coercion surface. "0" == 0 is true, "" == 0 is true, null == undefined is true. Always use === / !== unless you specifically want coercion (rare and worth a comment).

Mixing AMPscript and SSJS without commenting the boundary

In CloudPages and email blocks, AMPscript variables and SSJS variables can be passed back and forth with Variable.GetValue / Variable.SetValue. The boundary is invisible in the rendered diff. Always comment when you cross it. See Util / Variable / Request.

The discipline check before merging

Before any new SSJS block goes from preview into a published CloudPage or an activated Automation, walk through this checklist:

  • [ ] Asset name follows the prefix convention (CR_ / CP_ / SA_ / Auto_ / J_ / de_log_ssjs_ / de_log_errors_ / de_lookup_ssjs_)
  • [ ] Platform.Load("Core", "1.1.5") is line 1
  • [ ] RUN_ID generated once via Platform.Function.GUID() at the top
  • [ ] Every meaningful step writes to de_log_ssjs_runs with RunId, Step, Ts, Message
  • [ ] All var declarations grouped at the top of their scope
  • [ ] All DE names, column names, and status values pulled into constants
  • [ ] No let / const / arrow / template literal / destructuring (SpiderMonkey 1.7 parses ES5 only)
  • [ ] No console.log, no Promise, no setTimeout
  • [ ] Every try has a matching catch that writes to de_log_errors_* before deciding what to do
  • [ ] Every UpsertData / InsertData / UpdateData has explicit column lists
  • [ ] Destination DE primary key verified before any UpsertData reaches production
  • [ ] LookupRows calls are bounded (you expect under 2500 and assert on it, or you've already pivoted to SQL/WSProxy)
  • [ ] WSProxy re-instantiated every 15 minutes in any loop expected to run over 10 minutes
  • [ ] All external HTTP calls upper-bounded by iteration count
  • [ ] Runtime instrumented in any Script Activity with bulk reads or external callouts
  • [ ] === / !== used unless type coercion is explicitly intended and commented
  • [ ] Comments explain the why of any non-obvious choice
  • [ ] Any "temporary" code has an expiration date and a calendar reminder

When all eighteen fire, the script is ready to ship.

Related

Catalog progress: with this Style Guide, all 9 reference + decision-framework pages in the SSJS section are shipped, alongside the gotchas production-note. The remaining catalog work is 3 how-to debugging snippets (stuck Script Activities, WSProxy auth issues, silent UpsertData duplicates).

If you spot a rule missing — or one of these rules being violated in our public work — write to hello@wearecleon.com. We add it, or we fix it and we say so.