Gotchas de MC SSJS: lo que realmente rompe en producción
Server-Side JavaScript en Marketing Cloud es JavaScript como era en 2010 — SpiderMonkey 1.7, sin sintaxis moderna, single-threaded, con una API Platform específica de Salesforce arriba. Diez gotchas que Cleon vio romper a escala, con los patrones que sobreviven.
SSJS en Marketing Cloud se lee como JavaScript, parece JavaScript, y se rompe como JavaScript de un universo paralelo donde ES6 nunca pasó. El runtime es SpiderMonkey 1.7 (released 2007). La biblioteca estándar es lo mínimo. La API Platform.* específica de Salesforce es lo que realmente usás, y tiene sus propias reglas para cuándo funciona y cuándo aparenta funcionar.
La lista de abajo es el set de cosas que nos mordieron fuerte en producción. Cada una viene con el patrón que evita que el script tire "Internal server error" a las 2 de la mañana.
Los gotchas
1. SpiderMonkey 1.7. Sin JavaScript moderno.
No tenés let, const, arrow functions, template literals, destructuring, spread, Object.assign, Promise nativo, async/await, optional chaining, ni los métodos de array que aterrizaron después de ES5. Si tu editor te está autocompletando sintaxis moderna, el editor te está mintiendo sobre lo que corre.
// ESTO NO FUNCIONA
const subscriberKey = '12345'; // SyntaxError
const greet = (name) => `Hola, ${name}!`; // SyntaxError × 2
const { firstName, lastName } = subscriber; // SyntaxError
const merged = { ...defaults, ...overrides }; // SyntaxError
// ESTO FUNCIONA
var subscriberKey = '12345';
function greet(name) {
return 'Hola, ' + 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];Escribí SSJS como escribirías JavaScript antes de que existiera jQuery. var, function, loops tradicionales, trabajo manual con objetos. El costo es verbosidad; el premio es código que corre.
2. Platform.Load("Core","1.1.5") es obligatorio al tope de cada script.
Sin esto, todo el namespace Platform.* no existe — y el mensaje de error que recibís no dice nada del load call faltante. Vas a ver Platform is not defined o, peor, Platform.Function.RetrieveSalesforceObjects is not a function y vas a perder una hora buscando el code path equivocado.
<script runat="server">
Platform.Load("Core", "1.1.5");
// Ahora Platform.Function.* y Variable.* y el resto existen
var rows = Platform.Function.LookupRows("master_subscribers", "SubscriberKey", "12345");
</script>La versión 1.1.5 es el estándar entre los tenants SFMC. Algún código legacy usa Core sin versión (Platform.Load("Core")); esto sigue funcionando pero pin-ea a lo que sea que el tenant defaultee, lo que cambió entre ediciones. Siempre especificá la versión.
3. El timeout duro de 30 minutos es el mismo que el de las SQL Activities.
Una Cloud Page o una SSJS Script Activity que corre más de 30 minutos se mata. Callouts largos, iteraciones grandes de DE con LookupRows, o loops sin acotar comen el budget sin warning. No hay notificación a los 25 minutos; el script o termina o desaparece.
4. Platform.Function.UpsertData es upsert solo en el nombre.
La función SSJS de upsert parece una escritura transaction-safe: la fila existe por primary key → update; la fila no existe → insert. En la práctica tiene dos formas de falla que no surfacean como errores:
- PK equivocada o faltante en el DE de destino → inserta duplicados en lugar de actualizar.
- Mismatch de tipo en una columna no-key → truncación o coerción silenciosa (igual que el gotcha de truncación de longitud de SQL — #5).
// EN RIESGO — asume que el DE de destino tiene SubscriberKey como PK; si no,
// vas a seguir agregando filas
Platform.Function.UpsertData(
"master_subscribers",
["SubscriberKey"], [subKey],
["EmailAddress","Status"], [email, "Active"]
);
// DEFENSIVO — verificá la configuración de PK del destino en el log de
// corrida de Data Extension antes de correr el upsert en producciónLa defensa es la misma que en SQL: verificá la configuración de primary key del DE de destino antes de que cualquier código de "upsert" llegue a producción. La función no te va a decir que la configuración está mal — solo va a hacer la cosa equivocada en silencio.
5. Platform.Function.LookupRows devuelve a lo sumo 2500 filas.
La firma sugiere "dame todas las filas que matcheen este filtro." La realidad es que la función capa a 2500. Los callers que necesitan más filas obtienen las primeras 2500 en silencio y se pierden el resto, lo que generalmente surfacea semanas después como una métrica más baja de lo que debería ser.
// EN RIESGO — asume que LookupRows devuelve todo lo que matchea
var rows = Platform.Function.LookupRows("master_subscribers", "Status", "Active");
// Si hay 100k subscribers Active, obtenés los primeros 2500.
// DEFENSIVO — capá explícitamente y documentá la limitación
var rows = Platform.Function.LookupRows("master_subscribers", "Status", "Active");
if (rows.length === 2500) {
// Pegamos en el cap. O iterá con un approach de page-key o
// pivoteá a una SQL Query Activity que pueda manejar el volumen.
Write("LIMIT-HIT: LookupRows devolvió 2500 — probablemente truncado");
}Para semántica real de "todas las filas", bajá a WSProxy con RetrieveAsync (sigue siendo paginado, pero vos controlás el page size y podés iterar) o pivoteá a una SQL Query Activity diseñada para lecturas bulk.
6. console.log no existe. El output va por Write().
No hay consola. El debug output en un script de CloudPage rendea en la página misma vía Write(). En una Script Activity de Automation Studio, el output se captura al log de corrida de la Activity solo cuando también llamás al wrapper correcto.
// En un bloque SSJS de CloudPage — outputea al HTML rendereado
Write("Got " + rows.length + " rows");
// En una Script Activity de Automation Studio — escribí a un DE de log
Platform.Function.UpsertData(
"de_log_ssjs_runs",
["RunId"], [runId],
["Step","Message","Ts"], ["lookup", "got " + rows.length + " rows", Now()]
);El patrón Cleon: cada SSJS de producción escribe a un DE de_log_ssjs_runs en cada paso. El DE tiene columnas RunId, Step, Message, Ts. Cuando algo se ve raro, queryeás el log en lugar de tratar de reconstruir qué pasó.
7. Try/catch traga errores en silencio salvo que loguees adentro del catch.
Un try { ... } catch (e) {} pelado tiene éxito incluso cuando el cuerpo falla. La Activity reporta éxito porque el catch no re-tira. Días después te preguntás por qué un valor nunca se escribió — la respuesta está en un bloque catch que nunca logueó.
// EN RIESGO — el error se traga, la Activity loggea una corrida exitosa
try {
Platform.Function.UpsertData("master_subs",
["SubscriberKey"], [key],
["Status","UpdatedAt"], [status, Now()]);
} catch (e) {
// vacío — falla silenciosa
}
// DURABLE — silencioso solo después de loguear
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()]);
}Mismo principio que la regla SQL de errores no deben pasar en silencio — silencioso solo después de loguear la razón.
8. Los tokens de auth de WSProxy expiran cada ~20 minutos.
Los scripts SSJS de larga duración que usan WSProxy para acceso a la SOAP API necesitan manejar la expiración de tokens. La primera llamada funciona; la llamada a los 25 minutos devuelve un error de auth que no viste durante el testing porque tu test duró 30 segundos.
// EN RIESGO — las primeras llamadas funcionan, las llamadas pasada la marca
// de los 20 min fallan
var prox = new Script.Util.WSProxy();
for (var i = 0; i < bigList.length; i++) {
prox.retrieve("Subscriber", ["EmailAddress"], filter); // muere en la fila N
}
// DURABLE — re-instanciá periódicamente para conseguir un token fresco
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);
}Refrescá cada 15 minutos (margen de seguridad de 5 min bajo la expiración de 20 min).
9. Los callouts Platform.Request tienen timeout de 60 segundos por request.
Las llamadas HTTP externas desde SSJS vía HTTP.Get / HTTP.Post hacen timeout a los 60 segundos por llamada. APIs de terceros lentas (endpoints de bulk export de CRM, cualquier cosa síncrona contra otro tenant) explotan esto regularmente.
// EN RIESGO — una sola llamada a una API lenta, timeout a los 60s
var resp = HTTP.Get("https://slow-api.example.com/big-export?since=2026-01-01");
// DURABLE — paginá contra la API, cada página bajo el timeout
var page = 0;
var hasMore = true;
while (hasMore && page < 100) { // upper bound para prevenir runaway
var resp = HTTP.Get("https://api.example.com/export?page=" + page + "&size=500");
// procesar resp...
page++;
// hasMore = ... derivado de la respuesta
}Plus: las llamadas HTTP externas compoundean con el timeout de 30 minutos de la Activity. Diez llamadas de 60 segundos comen 1/3 del budget total.
10. SSJS es single-threaded y síncrono. Sin promises, sin setTimeout.
No hay async. Cada línea espera a que la línea anterior complete. No hay Promise, no hay await, no hay event loop, no hay primitiva de concurrencia. Una operación de larga duración en la línea 5 bloquea las líneas 6 a 200.
// Este es el modelo de concurrencia entero: hacé una cosa, después la siguiente.
var rows = Platform.Function.LookupRows("master_subs", "Status", "Active");
for (var i = 0; i < rows.length; i++) {
// Cada iteración espera. No hay Promise.all, no hay iteración paralela.
Platform.Function.UpsertData(
"de_log_processed",
["SubscriberKey"], [rows[i].SubscriberKey],
["ProcessedAt"], [Now()]
);
}Si necesitás concurrencia, el patrón es partir el trabajo entre múltiples Automations o Script Activities que corran en paralelo a nivel de scheduling de Automation Studio — no adentro de un solo SSJS.
Cierre
Estos diez son las formas de falla que se comieron más horas de producción. Cleon escribe SSJS sabiendo que cualquiera de ellos puede dispararse en la próxima corrida, y los scripts que entregamos son los que sobreviven a esa asunción — modestos en sintaxis, instrumentados en cada paso, defensivos contra cada llamada externa.
Si encontrás un gotcha que mordió a tu equipo y no está acá — escribinos a hello@wearecleon.com. Lo agregamos, con crédito.