n8n-io/n8n · error · InvalidLifecycleOptionsError

concurrencyMode must be 'sequential' or 'concurrent', got ${

Error message

concurrencyMode must be 'sequential' or 'concurrent', got ${String(lifecycleOptions.concurrencyMode)}

What it means

Thrown by createScheduler when lifecycleOptions.concurrencyMode is neither 'sequential' nor 'concurrent'. The mode decides whether executor passes run one-at-a-time or overlap; an unknown value would branch into undefined behavior downstream, so the factory rejects it before any pass starts.

Source

Thrown at packages/@n8n/scheduler/src/core/factory.ts:176

		'materializerTimeoutSeconds',
		'executorTimeoutSeconds',
		'reaperTimeoutSeconds',
		'retentionTimeoutSeconds',
	] as const;
	for (const key of durationKeys) {
		const value = lifecycleOptions[key];
		if (!(Number.isFinite(value) && value > 0)) {
			throw new InvalidLifecycleOptionsError(
				`${key} must be a positive number of seconds, got ${value}`,
			);
		}
	}

	if (
		lifecycleOptions.concurrencyMode !== 'sequential' &&
		lifecycleOptions.concurrencyMode !== 'concurrent'
	) {
		throw new InvalidLifecycleOptionsError(
			`concurrencyMode must be 'sequential' or 'concurrent', got ${String(lifecycleOptions.concurrencyMode)}`,
		);
	}

	const { maxConcurrentPasses } = lifecycleOptions;
	if (!(Number.isInteger(maxConcurrentPasses) && maxConcurrentPasses >= 1)) {
		throw new InvalidLifecycleOptionsError(
			`maxConcurrentPasses must be a positive integer, got ${maxConcurrentPasses}`,
		);
	}

	const emit = (level: SchedulerEventLevel, message: string, context: Record<string, unknown>) => {
		// The sink is the reporting channel itself: if it throws (a broken logger),
		// there is nothing left to report through, and the pass that emitted must
		// not be broken by its own observability.
		try {
			onEvent?.({ level, message, context });
		} catch {

View on GitHub (pinned to 5ac6606e81)

Solutions

  1. Set lifecycle.concurrencyMode to exactly 'sequential' or 'concurrent'.
  2. Trim and lowercase config-derived values: (raw ?? '').trim().toLowerCase().
  3. If the value comes from an env var, validate against an allowlist in your config loader.
  4. Omit concurrencyMode to accept the DEFAULT_LIFECYCLE_OPTIONS default.

Example fix

// before
createScheduler({ lifecycle: { concurrencyMode: cfg.mode } }); // cfg.mode = 'parallel' -> throws

// after - normalize against an allowlist
const MODES = ['sequential', 'concurrent'] as const;
const concurrencyMode = MODES.includes(cfg.mode as any)
  ? (cfg.mode as typeof MODES[number])
  : 'concurrent';
createScheduler({ lifecycle: { concurrencyMode } });
Defensive patterns

Strategy: type-guard

Validate before calling

const MODES = ['sequential', 'concurrent'] as const;
type ConcurrencyMode = typeof MODES[number];

function normalizeMode(raw: unknown): ConcurrencyMode {
  const v = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
  return (MODES as readonly string[]).includes(v) ? (v as ConcurrencyMode) : 'concurrent';
}

Type guard

function isConcurrencyMode(v: unknown): v is 'sequential' | 'concurrent' {
  return v === 'sequential' || v === 'concurrent';
}

Prevention

When it happens

Trigger: Passing lifecycle.concurrencyMode: 'parallel', 'concurrent-', undefined-after-defaults (withDefaults did not set it), null, or a value with trailing whitespace ('concurrent ').

Common situations: A typo in config (e.g. 'concurrent' vs 'concurent'); a config schema that allowed arbitrary strings; an upgrade that renamed the mode and left stale config; whitespace from an env var not trimmed.

Related errors


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/2c92e4be009bebe6. Report an issue: GitHub.