go-bpm RU

Performance

The project repeats the simple process benchmark from the 2021 article Zeebe and Camunda under high load on the same machine: Intel Core i7-4770K, four cores, 24 GB RAM and SSD storage.

Results at a glance#

  • With PostgreSQL history, synchronous methods reach 1 153 instances/s, and service tasks reach 419 instances/s.
  • Without history, methods reach 1 361 instances/s. Every process step still commits to PostgreSQL.
  • History adds roughly 10–15% overhead in these runs because its rows share the step transaction.
  • With synchronous_commit=off, service tasks with history reach 752 instances/s, with the durability tradeoff explained below.

Comparison charts#

Throughput is completed instances divided by elapsed time from the first start, for 100,000 starts. go-bpm bars are the median of three runs; hover or focus a bar to see the range. Other systems' values come from the 2021 article, rather than new runs.

Without history#

  • Apache Camel4 154
  • Spring Integration3 910
  • bpm-light, without export2 235
  • Camunda, without history (H2 in memory)1 577
  • go-bpm, methods, without history1 361
  • go-bpm, service task, without history466
  • Zeebe, without export117

With history or export#

  • bpm-light, export to H2 (file)1 960
  • go-bpm, methods, history, async commit1 228
  • go-bpm, methods, history1 153
  • bpm-light, export to Elasticsearch1 102
  • bpm-light, export to ClickHouse983
  • go-bpm, service task, history, async commit752
  • go-bpm, service task, history419
  • Camunda, history in PostgreSQL293
  • Zeebe, export to Elasticsearch112

Camel and Spring Integration do not persist BPM instance state and do not resume failed runs after restart. They serve as a throughput reference for this hardware.

Checkpoints#

Average throughput since the beginning at each completion checkpoint. go-bpm uses the median run.

System1k10k20k30k40k50k60k70k80k90k100k
Without history
Apache Camel2 2174 0294 0874 1544 1354 1384 1284 1124 1674 1304 154
Spring Integration1 9963 7573 8593 8913 8983 8993 8883 9003 9143 9223 910
bpm-light, without export1 2442 1992 2222 2432 2422 2382 2362 2362 2322 2372 235
Camunda, without history (H2 in memory)9711 5651 5691 5791 5841 5821 5911 5871 5931 5821 577
go-bpm, methods, without history2 5082 4222 4092 2982 2061 8981 6411 6001 6831 6101 361
go-bpm, service task, without history173549568652496408435446469471466
Zeebe, without export100112117118119119118118119117117
With history or export
bpm-light, export to H2 (file)1 1051 9431 9701 9761 9781 9601 9671 9571 9721 9631 960
go-bpm, methods, history, async commit1 3195974716407818979921 0711 1341 1861 228
go-bpm, methods, history1 4221 1195917598879731 0161 0641 1091 1391 153
bpm-light, export to Elasticsearch3079001 1071 1051 1031 0991 0701 0891 1051 0301 102
bpm-light, export to ClickHouse7009991 0129911 0039811 0021 0039991 004983
go-bpm, service task, history, async commit575191305371406454513572637696752
go-bpm, service task, history761550412347398435448438450455419
Camunda, history in PostgreSQL279346302331306319296309303296293
Zeebe, export to Elasticsearch88106112114113115113113113113112

Methodology#

The process is start → two REST calls → end. Each call requests static {} with a random ?v= parameter to avoid caching. The article used nginx; these runs use an HTTP stub inside the Go process.

  • Methods: both calls run synchronously in one system queue step. A failure in the second call repeats the entire step.
  • Service tasks: each call has a separate queued handler and retry budget, adding transaction overhead.

The actual benchmark methods and handlers:

// bench/internal/bpmgen/bench_methods/method_rest2.go
// Rest2 - метод задачи Rest2 «Вызов REST №2»
func Rest2(ctx context.Context, svc *services.Services, data *model.DataModel) error {
	if err := svc.Rest.Call(ctx); err != nil {
		return err
	}
	svc.Finished()
	return nil
}
// bench/internal/bpmgen/handlers/rest_call.go
// RestCall - обработчик service task с jobType "restCall" во всех процессах, где он встречается
// (job.ProcessName, job.ElementId). Вызывается асинхронно of очереди задач и при ошибке повторяется,
// поэтому должен быть идемпотентным (ключ - job.InstanceUid + job.ElementId)
func RestCall(ctx context.Context, svc *services.Services, job bpm.Job, data *model.DataModel) error {
	if err := svc.Rest.Call(ctx); err != nil {
		return err
	}
	if job.ElementId == "Rest2" {
		svc.Finished()
	}
	return nil
}

Eight goroutines start 100,000 instances through StartProcess; 16 system workers and 16 service task workers execute them. Each variant runs three times, clearing the dedicated benchmark database between runs.

GO_BPM_BENCH_DSN='postgres://postgres:postgres@localhost:5432/go_bpm_bench?sslmode=disable' ./run.sh

Environment: Intel(R) Core(TM) i7-4770K CPU @ 3.50GHz (8 threads), PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), go1.26.8, 2026-10-26; before and after runs, the database sustained from 5 534 to 36 620 disk-flushed commits per second. PostgreSQL 18 runs in Docker with host networking, shared_buffers=2GB and max_wal_size=4GB; remaining settings use defaults. synchronous_commit=on except where explicitly marked.

Interpretation and limitations#

The comparison systems were measured in 2021 with different Java, Camunda and Zeebe versions. Treat this as an order-of-magnitude comparison on the same hardware, not a current product ranking.

Disk commit throughput varied substantially between runs. Four CPU cores share starters, workers, the stub and PostgreSQL. Throughput changes after the starters finish enqueueing; charts therefore show medians and ranges.

The article used default PostgreSQL settings; go-bpm used the tuning above. With defaults and Docker port forwarding on this machine, method throughput was about 1,000–1,900 instances/s and service tasks about 350–500.

synchronous_commit=off acknowledges commits before WAL is flushed to disk. A database server crash can lose recent acknowledged transactions. Use that configuration only when the application's durability requirements permit it.

A bug found by the benchmark#

Early runs sometimes stalled for about 40 seconds at the end: a queue claim leased several tasks but delivered only one to a worker, leaving others waiting for lease expiry. This was fixed in d7d82c6. The results above include the fix.

Suggest an edit on GitHub