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.

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.

Appendix: 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)