Skip to content
SuiteScriptPerformanceScript Types

NetSuite Scheduled Script vs Map/Reduce: A Decision Framework

ERP Suite Code13 min readUpdated
On this page

A few years ago, a developer I was reviewing code for had spent two days converting a SuiteScript Scheduled Script to a Map/Reduce script. The job processed 200 vendor bills per night. When I asked why, he said: "Map/Reduce scales better." He wasn't wrong in principle. But for 200 records, the M/R setup overhead — queue provisioning, stage serialization, the slightly longer deployment cycle — made the job slower, harder to debug, and more complex for the next person to maintain.

Two weeks later, a different client called. Their nightly Scheduled Script was failing with a governance error on record #333. They'd been trying to process 150,000 vendor bills in a single `execute()` function, loading and saving each one. Their conclusion: "NetSuite must have a bug."

Both scenarios are common. Both are completely avoidable. The choice between Scheduled Script and Map/Reduce isn't about which is "better" — it's about matching the tool to the job. This is the framework I use, grounded in the governance math and real production numbers.

Two Mistakes I See Constantly

In over a decade of reviewing SuiteScript code across dozens of implementations, the mistakes cluster into two camps:

  • Over-engineering: Using Map/Reduce for small, simple jobs because it "scales". The result is unnecessary complexity, harder debugging, and often slower execution for datasets under ~2,000 records.
  • Under-engineering: Using Scheduled Script for large datasets. The result is governance failures, jobs that silently process only a fraction of the data, and brittle workarounds involving custom status fields and batch tracking.

The fix is understanding what each script type is actually doing under the hood — specifically, how governance units flow — and then applying a simple decision framework.

The Governance Bottleneck

NetSuite's governance system is a rate-limiter built into the execution environment. Every API call consumes governance units. When a script exhausts its budget, execution stops — not gracefully, but with an error. Your data is partially processed and you may not know which records made it through.

The unit costs that matter most in bulk processing jobs:

  • record.load() → 10 governance units per call
  • record.save() → 20 governance units per call
  • search.create().run().getRange() → ~10 units per 1,000 results returned
  • record.submitFields() → 10 units (cheaper than load + save for simple field updates)

Why this matters immediately

At 10 units to load and 20 units to save, each record costs 30 governance units minimum. A Scheduled Script's default budget of 10,000 units means you can process roughly 333 records before hitting the limit — if load and save are all you're doing. Add any custom logic per record and that ceiling drops further.

How Scheduled Scripts Actually Work

A Scheduled Script runs in a single execution context. One `execute()` function, one governance budget, one thread. It can be triggered on a schedule, manually, or via a RESTlet/Suitelet call.

The execution model is completely synchronous from your perspective — everything in `execute()` runs in order, one record at a time. That simplicity is both its strength and its limitation.

scheduled_process_orders.jsGov: 30u
/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 *
 * Processes pending sales orders — flags them as reviewed.
 * At 30 governance units per record (10 load + 20 save), this
 * handles approximately 333 records before hitting the 10,000
 * unit limit. Fine for daily jobs on small order volumes.
 */
define(['N/search', 'N/record', 'N/log'], (search, record, log) => {

  const execute = (context) => {
    const pendingOrders = search.create({
      type: search.Type.SALES_ORDER,
      filters: [
        ['status', 'is', 'pendingFulfillment'],
        'AND',
        ['custbody_reviewed', 'is', false],
      ],
      columns: ['internalid', 'trandate', 'entity', 'amount'],
    });

    // WARNING: This pattern fails silently above ~333 records.
    // The search returns all results but the forEach loop will hit
    // governance long before it finishes a large result set.
    pendingOrders.run().each((result) => {
      try {
        // 10 units
        const rec = record.load({
          type: record.Type.SALES_ORDER,
          id: result.id,
        });

        rec.setValue({ fieldId: 'custbody_reviewed', value: true });
        rec.setValue({ fieldId: 'custbody_reviewed_date', value: new Date() });

        // 20 units
        rec.save({ ignoreMandatoryFields: true });

        log.debug({ title: 'Processed', details: result.id });
      } catch (e) {
        log.error({ title: `Failed on ${result.id}`, details: e.message });
      }
      return true; // continue iteration
    });
  };

  return { execute };
});

The `search.run().each()` pattern looks clean, but it's dangerous at scale. NetSuite will stop the loop mid-execution when governance runs out — it won't tell you how many records were skipped, and the script may log success.

The safer Scheduled Script pattern for medium datasets

Use a 'batch cursor' approach: process records in chunks of 200-250, store the last processed ID or a status flag in a custom record, and let the scheduler run again on the next cycle to pick up where it left off. This is more complex than Map/Reduce for large datasets — which is exactly when you should use Map/Reduce instead.

How Map/Reduce Actually Works

Map/Reduce is not a faster Scheduled Script. It's a fundamentally different execution model designed for parallel, fault-tolerant processing of large datasets. Understanding this distinction is the key to using it well.

The framework distributes work across multiple concurrent queues. Each queue processes independently, with its own governance budget. A failure in one queue doesn't kill the job — only the specific key that failed is retried or logged as failed, while all other keys continue processing.

The Four Stages

  1. getInputData() — Runs once. Returns the full input dataset (typically a Search). NetSuite automatically paginates the results. Governance budget: 10,000 units.
  2. map() — Called once per input record. Each call is isolated — its own context, its own governance budget. This is where parallelism happens. Outputs key/value pairs.
  3. reduce() — Called once per unique key, with all values that map() wrote for that key. Use this when you need aggregation. Entirely optional — skip it if you don't need grouping.
  4. summarize() — Runs once after all map/reduce work completes. Use for cleanup, error logging, triggering follow-up actions. Governance budget: 10,000 units.
mapreduce_process_orders.js
/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 *
 * Same job as the Scheduled Script above — but now processes
 * 150,000 records safely. Each map() call has its own governance
 * budget, so the 30-unit cost per record is never pooled.
 */
define(['N/search', 'N/record', 'N/log'], (search, record, log) => {

  /**
   * Stage 1: Return the full input set.
   * NetSuite handles pagination automatically — you don't need
   * getRange() or manual chunking. Return the Search object itself.
   * Governance: 10,000 units for this stage (rarely a concern here).
   */
  const getInputData = () => {
    return search.create({
      type: search.Type.SALES_ORDER,
      filters: [
        ['status', 'is', 'pendingFulfillment'],
        'AND',
        ['custbody_reviewed', 'is', false],
      ],
      columns: ['internalid', 'trandate', 'entity', 'amount'],
    });
  };

  /**
   * Stage 2: Called once per record from getInputData.
   * Each invocation has its own governance budget — ~10,000 units.
   * Processing 150,000 records costs 150,000 × 30 = 4.5M units
   * total, but never more than 30 units at a time. Safe.
   *
   * If this throws, only THIS record fails — the job continues.
   */
  const map = (context) => {
    // context.key   = internal ID of the search result
    // context.value = JSON of all returned columns
    const rec = record.load({
      type: record.Type.SALES_ORDER,
      id: context.key,
    });

    rec.setValue({ fieldId: 'custbody_reviewed', value: true });
    rec.setValue({ fieldId: 'custbody_reviewed_date', value: new Date() });
    rec.save({ ignoreMandatoryFields: true });

    // Write output for summarize() to count
    context.write({ key: context.key, value: 'processed' });
  };

  /**
   * Stage 4: Runs once after all map() calls complete.
   * Log errors, trigger downstream workflows, send notifications.
   * IMPORTANT: mapSummary.errors are the records that failed — check these.
   */
  const summarize = (context) => {
    const errorCount = context.mapSummary.errors.count;

    if (errorCount > 0) {
      context.mapSummary.errors.iterator().each((key, errorJson) => {
        const err = JSON.parse(errorJson);
        log.error({
          title: `Map failed: Order ${key}`,
          details: `${err.name}: ${err.message}`,
        });
        return true;
      });
    }

    log.audit({
      title: 'Job complete',
      details: `Processed: ${context.mapSummary.keys.count}, Errors: ${errorCount}`,
    });
  };

  return { getInputData, map, summarize };
});

The governance insight

Map/Reduce doesn't give you more governance units — it gives you parallel governance. Each map() call is isolated. The 30-unit cost for load + save never accumulates in a shared pool. This is why M/R can safely process 500,000 records while a Scheduled Script fails at 333.

The Governance Math

Let's make this concrete. You have a job that loads a record, updates two fields, and saves. That's 30 governance units per record (10 load + 20 save). Here's what that means for each script type:

governance_analysis.js
// ── Governance Analysis: 30 units per record ─────────────────────────────

// SCHEDULED SCRIPT
// Budget:       10,000 units per execution
// Cost/record:  30 units (load + save)
// Max records:  10,000 / 30 = ~333 records safely
//               (less if you have search overhead, logging, custom logic)
//
// To process 10,000 records → need ~30 scheduled runs or a batch cursor
// To process 150,000 records → need ~450 scheduled runs (impractical)

// MAP/REDUCE SCRIPT
// Budget:       Each map() call is isolated — not cumulative
// Cost/record:  30 units consumed within a single isolated call
// Max records:  Effectively unlimited for load+save operations
//               (5 concurrent queues by default process in parallel)
//
// 150,000 records at 30 units each:
// — With 5 queues: ~30,000 parallel units consumed simultaneously
// — Total wall-clock time vs Scheduled: dramatically faster
// — Governance risk: zero (each call is isolated)

// record.submitFields() as an optimization
// If you're only changing 1-3 fields, skip load() entirely:
// Cost: 10 units (vs 30 for load+save) = 3× more records per budget

import { record } from 'N/record';

// Optimised pattern — 10 units instead of 30
record.submitFields({
  type: record.Type.SALES_ORDER,
  id: '12345',
  values: {
    custbody_reviewed: true,
    custbody_reviewed_date: new Date(),
  },
  options: { ignoreMandatoryFields: true },
});

The submitFields trap

record.submitFields() is cheaper (10 units) and often faster — but it bypasses beforeSubmit User Event Scripts on the target record. If your workflow depends on those events firing, you must use the full load/save cycle even at 3× the governance cost.

Performance at Scale: Real Numbers

Here's what I've measured across production environments doing load+save operations with moderate per-record logic (field updates, 1-2 lookups). These are real observations, not theoretical benchmarks.

100 records
Scheduled Script: ~45 seconds. Map/Reduce: ~90-120 seconds (queue provisioning overhead dominates). Winner: Scheduled Script.

1,000 records
Scheduled Script: ~4.5 minutes (assuming batch cursor). Map/Reduce: ~3-4 minutes (parallelism starts paying off). Winner: roughly even, Scheduled marginally simpler.

5,000 records
Scheduled Script: ~22-25 minutes across multiple cycles (batch cursor required). Map/Reduce: ~8-10 minutes (5 queues running concurrently). Winner: Map/Reduce.

50,000 records
Scheduled Script: hours across many cycles, fragile, complex state management. Map/Reduce: ~60-80 minutes. Winner: Map/Reduce, not close.

The crossover point

In my experience, the crossover is around 2,000-3,000 records for jobs with moderate per-record complexity. Below that, Scheduled Script is simpler, equally fast, and easier to debug. Above that, Map/Reduce's parallelism and isolated governance model make it the right choice.

The Decision Framework

Three questions determine the right script type for almost every job I've encountered.

The Three Questions

1. How many records? Under 2,000 → Scheduled. Over 5,000 → Map/Reduce. 2,000-5,000 → depends on questions 2 and 3. 2. Does processing order matter or does the job require complex aggregation? Yes → reconsider Scheduled or use M/R reduce stage. 3. What happens if a single record fails? Abort the job → Scheduled. Skip and continue → Map/Reduce.

Use a Scheduled Script When...

  • Record count is reliably under ~2,000 per execution
  • Execution order matters — later records depend on the outcome of earlier ones
  • You need the job to run at a precise time and complete reliably within a window
  • The job logic is simple enough that a single execute() function stays under 80 lines
  • You need to trigger the script programmatically via a Suitelet or Restlet and wait for synchronous-ish behaviour
  • You're prototyping — Scheduled Script is much faster to iterate on
  • The job needs to be manually triggered by a non-technical user via a button or menu item

Use Map/Reduce When...

  • Record count exceeds 2,000-3,000 and cannot be predicted to stay below that
  • Processing is embarrassingly parallel — each record can be handled independently
  • A single bad record should not abort processing of the remaining records
  • You need aggregation: group-by logic, roll-ups, or tallying results across the dataset
  • The job runs on a large dataset that grows over time (data volume is a moving target)
  • You need fault tolerance with a full error report of which records failed and why
  • Processing a full export or data migration where completeness is non-negotiable

Where Map/Reduce Overhead Works Against You

Map/Reduce has real overhead that most documentation doesn't quantify:

  • Queue provisioning: M/R takes 30-90 seconds to start executing after deployment/trigger, even on small datasets. A Scheduled Script starts in seconds.
  • Stage serialization: Data passed between stages must be JSON-serializable. You can't pass a live record object from getInputData to map — only primitives and plain objects. This trips up developers who are used to Scheduled Script's single execution context.
  • Debugging latency: Watching M/R logs requires waiting for stage transitions. A Scheduled Script surfaces its full output log immediately on completion.
  • Context isolation: Every map() call starts with a clean context. If you need to initialise an expensive object (an external API client, a lookup table), you're re-initialising it for every single record unless you handle it in getInputData and pass the data through.
bad_map_reduce_pattern.js
/**
 * ANTI-PATTERN: Using Map/Reduce for a tiny, time-sensitive job.
 *
 * This runs every 5 minutes to check a single custom record for
 * a status flag and send a notification if it's changed.
 *
 * Problems:
 * 1. M/R provisioning overhead (30-90s) makes "every 5 minutes" meaningless
 * 2. 1 record — Scheduled Script would be 10× simpler
 * 3. The summarize stage adds latency before the notification fires
 */
const getInputData = () => {
  return search.create({
    type: 'customrecord_status_monitor',
    filters: [['custrecord_status', 'is', 'changed']],
    columns: ['internalid'],
  });
  // This returns 0 or 1 records. Map/Reduce is egregious overkill.
};

// ── BETTER: Use a Scheduled Script ────────────────────────────────────────
// For polling/notification jobs on small datasets, Scheduled Script
// is simpler, faster to start, and easier to debug. The overhead
// Map/Reduce adds is pure cost with zero benefit here.

Converting a Scheduled Script to Map/Reduce

When you've decided a job has grown beyond what a Scheduled Script can handle, here's the conversion pattern. The core refactor is moving your search into getInputData() and your per-record logic into map().

before_scheduled.js
// BEFORE: Scheduled Script that breaks at ~333 records

/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 */
define(['N/search', 'N/record', 'N/email', 'N/log'], (search, record, email, log) => {

  const execute = (context) => {
    const overdueInvoices = search.create({
      type: search.Type.INVOICE,
      filters: [
        ['status', 'is', 'open'],
        'AND',
        ['duedate', 'before', 'today'],
        'AND',
        ['custbody_reminder_sent', 'is', false],
      ],
      columns: ['internalid', 'entity', 'email', 'duedate', 'amountremaining'],
    });

    overdueInvoices.run().each((result) => {
      // 10 units load + 20 units save + email cost
      // Fails at ~250 invoices
      const rec = record.load({ type: record.Type.INVOICE, id: result.id });
      const entityEmail = result.getValue('email');

      email.send({
        author:  -5, // -5 = system default sender
        recipients: [entityEmail],
        subject: 'Invoice overdue notice',
        body: `Invoice #${rec.getValue('tranid')} is overdue.`,
      });

      rec.setValue({ fieldId: 'custbody_reminder_sent', value: true });
      rec.save({ ignoreMandatoryFields: true });

      return true;
    });
  };

  return { execute };
});
after_map_reduce.js
// AFTER: Map/Reduce — handles 150,000 invoices safely

/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 */
define(['N/search', 'N/record', 'N/email', 'N/log'], (search, record, email, log) => {

  // Step 1: Move the search here. Return the Search object directly —
  // M/R iterates automatically through all pages of results.
  const getInputData = () => {
    return search.create({
      type: search.Type.INVOICE,
      filters: [
        ['status', 'is', 'open'],
        'AND',
        ['duedate', 'before', 'today'],
        'AND',
        ['custbody_reminder_sent', 'is', false],
      ],
      columns: ['internalid', 'entity', 'email', 'duedate', 'amountremaining'],
    });
  };

  // Step 2: Move per-record logic here. Each invocation is isolated —
  // a failure on invoice #12345 won't stop invoice #12346 from processing.
  const map = (context) => {
    // context.value is the JSON-serialised search result row
    const values = JSON.parse(context.value);
    const entityEmail = values.values.email;

    // 10 units load + email + 20 units save — contained within this call
    const rec = record.load({ type: record.Type.INVOICE, id: context.key });

    email.send({
      author: -5,
      recipients: [entityEmail],
      subject: 'Invoice overdue notice',
      body: `Invoice #${rec.getValue('tranid')} is overdue.`,
    });

    rec.setValue({ fieldId: 'custbody_reminder_sent', value: true });
    rec.save({ ignoreMandatoryFields: true });

    // Write a success signal for the summary count
    context.write({ key: context.key, value: 'sent' });
  };

  // Step 3: Log results and handle errors
  const summarize = (context) => {
    const sent  = context.mapSummary.keys.count;
    const failed = context.mapSummary.errors.count;

    log.audit({ title: 'Overdue reminders', details: `Sent: ${sent}, Failed: ${failed}` });

    if (failed > 0) {
      context.mapSummary.errors.iterator().each((invoiceId, errorJson) => {
        const err = JSON.parse(errorJson);
        log.error({ title: `Failed: ${invoiceId}`, details: err.message });
        return true;
      });
    }
  };

  return { getInputData, map, summarize };
});

The conversion is mostly mechanical: search → getInputData(), loop body → map(), post-loop logic → summarize(). The main mental shift is accepting that map() calls share no state and that failing gracefully is the default, not something you have to build.

Four Common Mistakes

1. Using Map/Reduce for Tiny Datasets

If a job reliably processes fewer than 500 records, a Scheduled Script is almost always the better choice. M/R setup overhead is real — 30-90 seconds before the first map() call fires — and that cost has to be amortised across enough records to be worthwhile.

2. Trying to Share In-Memory State Across Stages

Each Map/Reduce stage runs in a fresh context. Variables you set in getInputData() don't exist in map(). Variables you set in map() don't exist in reduce(). The only way to pass data across stage boundaries is through context.write() (map → reduce) or through a NetSuite record / custom record (getInputData → map). Developers who miss this spend hours debugging behaviour that looks like data disappearing.

3. Skipping Error Handling in summarize()

The summarize() stage is where Map/Reduce tells you what went wrong. If you don't iterate through context.mapSummary.errors, failed records vanish silently. In a production job processing 10,000 invoices, you might never know that 47 of them failed. Always iterate errors in summarize, log them, and ideally write them to a custom error log record so the finance team can see what needs manual attention.

4. Not Building an Early-Exit Into Scheduled Scripts

Scheduled Scripts should check conditions early and exit cleanly if there's nothing to do. A script that runs every hour but does real work only at month-end should check the date in the first 5 lines and return immediately for the other 700+ runs. Every unnecessary execution consumes governance and appears in logs — if you're doing hourly runs, that noise makes genuine errors harder to spot.

scheduled_early_exit.js
/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 */
define(['N/runtime', 'N/log'], (runtime, log) => {

  const execute = (context) => {
    const today = new Date();
    const isLastDayOfMonth =
      new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() === today.getDate();

    // Early exit — 0 governance units wasted on no-op runs
    if (!isLastDayOfMonth) {
      log.debug({ title: 'Skip', details: 'Not last day of month — nothing to process.' });
      return;
    }

    // Proceed with actual processing...
  };

  return { execute };
});

The Scheduled Script vs Map/Reduce choice is one of the most consequential decisions in a SuiteScript implementation — it affects performance, governance costs, error handling, and maintainability. Get it right by matching the tool to the actual job size and complexity, not to a vague notion of which one is "more professional" or "more scalable".

Frequently Asked Questions

Can I pass data between Map/Reduce stages?
Only through context.write() from map() to reduce() — and through NetSuite records to/from any stage. In-memory variables don't cross stage boundaries. Each stage runs in a completely fresh execution context. Plan your data flow explicitly: use context.write() for map-to-reduce output, and use a custom record if getInputData needs to pass configuration to map().
What happens when a Scheduled Script hits governance mid-run?
Execution stops immediately. Any records processed before the limit are committed (they were saved), but there's no rollback of partial work and no built-in indication of how many records were skipped. The script may log as 'completed' even though it stopped early. This is why the batch cursor pattern or Map/Reduce is required for large datasets.
Can I trigger a Map/Reduce script from another script?
Yes — use task.create() with task.TaskType.MAP_REDUCE and task.submit(). The triggering script continues executing immediately; it doesn't wait for the M/R to finish. If you need to know when the M/R is done, poll task.checkStatus() in a separate Scheduled Script. There's no built-in callback mechanism.
How do I handle records that fail in Map/Reduce?
Failed keys are captured in context.mapSummary.errors (for map stage failures) and context.reduceSummary.errors (for reduce). Iterate these in summarize() to log details. NetSuite does not automatically retry individual key failures — if a record fails, it stays in the error set. Write failures to a custom error log record so operations teams can review and reprocess.
Is there a hybrid approach — Scheduled Script that spawns Map/Reduce?
Yes, and this is a useful pattern for jobs that need a condition check before launching bulk processing. A lightweight Scheduled Script checks whether the job should run at all (date check, record count threshold, feature flag), and if so, uses task.create() to spawn a Map/Reduce script. The Scheduled Script handles control flow; Map/Reduce handles volume. Keeps both scripts focused and testable.