Dlaczego Elixir, a nie Go, Node.js czy Java? Uczciwe porównanie
Kiedy mówimy klientom, że budujemy systemy w Elixirze, słyszymy zawsze to samo pytanie: „Dlaczego nie w czymś popularniejszym?" Fair enough. Go ma Google za sobą, Node.js ma największy ekosystem na świecie, Java działa w bankach od 25 lat. Dlaczego wybieramy język, o którym większość dyrektorów IT nigdy nie słyszała?
Ten artykuł to nie manifest fanboya. Każda z tych technologii jest dobra - w swoim kontekście. Naszym kontekstem są systemy biznesowe, które muszą działać 24/7, obsługiwać wielu użytkowników jednocześnie i rozwijać się latami bez przepisywania. W tym kontekście Elixir wygrywa - i zaraz pokażę dlaczego.
Cztery technologie w jednej tabeli
Zanim wejdziemy w szczegóły, szybki przegląd:
| Cecha | Elixir | Go | Node.js | Java |
|---|---|---|---|---|
| Rok powstania | 2012 | 2009 | 2009 | 1995 |
| Maszyna wirtualna | BEAM (Erlang) | Natywny binary | V8 (Chrome) | JVM |
| Typowanie | Dynamiczne | Statyczne | Dynamiczne | Statyczne |
| Współbieżność | Procesy BEAM (preemptive) | Goroutines (cooperative) | Event loop (single-thread) | Wątki OS + virtual threads |
| Paradygmat | Funkcyjny | Imperatywny | Multi-paradygmat | Obiektowy |
| Garbage collector | Per-process (< 1 ms) | Stop-the-world (< 1 ms) | Stop-the-world (do 100+ ms) | Stop-the-world (do 500+ ms) |
| Hot code reload | Wbudowany | Brak | Brak | Częściowy (JRebel) |
| Firma za projektem | Społeczność | IBM/Google | OpenJS Foundation | Oracle |
Problem nr 1: współbieżność
Każdy system biznesowy to system współbieżny. 50 użytkowników pracuje jednocześnie, generują zamówienia, aktualizują stany magazynowe, wysyłają powiadomienia. Pytanie nie brzmi „czy potrzebujesz współbieżności", tylko jak Twoja technologia ją obsługuje.
Node.js - jeden wątek, jedno wąskie gardło
Node.js działa na jednym wątku z event loopem. Świetnie radzi sobie z operacjami I/O (czytanie z bazy, HTTP requesty), ale:
// Node.js - problem z CPU-bound operacjami
app.get('/raport', async (req, res) => {
// To BLOKUJE cały serwer na czas generowania
const raport = generujRaportPDF(dane); // 3 sekundy CPU
res.send(raport);
});
// Podczas tych 3 sekund ŻADEN inny request nie jest obsługiwany
// 50 użytkowników czeka, aż jeden raport się wygenerujeRozwiązanie? Worker threads, klaster procesów, kolejka zadań. Ale to dodatkowa złożoność, której nie miałbyś w Elixirze:
# Elixir - każdy request w osobnym procesie
def generate_report(conn, params) do
# To NIE blokuje innych requestów
# BEAM automatycznie przełącza procesy co ~4000 redukcji
report = ReportGenerator.generate_pdf(data)
send_download(conn, report)
end
# 50 użytkowników generuje raporty jednocześnie
# Każdy w swoim procesie, scheduler równomiernie dzieli CPUGo - goroutines, ale bez izolacji
Go ma goroutines - lekkie „zielone wątki", świetne do współbieżności. Ale jest fundamentalna różnica:
// Go - goroutine z panikiem zabija cały program
func handleRequest(w http.ResponseWriter, r *http.Request) {
go func() {
// Jeśli ta goroutine spowoduje panic...
result := processOrder(r)
// ...i nikt nie napisał recover()...
// ...cały serwer pada
}()
}
// Musisz RĘCZNIE obsługiwać każdy możliwy błąd
// Musisz RĘCZNIE pisać recover() w każdej goroutine
// Musisz RĘCZNIE zarządzać lifecycle goroutines# Elixir - crash jednego procesu nie wpływa na resztę
def handle_request(conn, params) do
# Jeśli ten proces upadnie...
result = OrderProcessor.process(params)
# ...supervisor automatycznie go zrestartuje
# ...żaden inny użytkownik nie odczuje problemu
# ...błąd zostanie zalogowany
json(conn, result)
end
# Zero ręcznej obsługi - supervisor tree robi to za CiebieW Go musisz sam zbudować to, co BEAM daje out-of-the-box: izolację błędów, restart, nadzór procesów.
Java - wątki, które kosztują
Java tradycyjnie używa wątków OS - ciężkich, kosztownych pamięciowo:
| Zasób | Proces BEAM | Goroutine (Go) | Virtual Thread (Java 21+) | Wątek OS (Java classic) |
|---|---|---|---|---|
| Pamięć startowa | ~2.6 KB | ~8 KB | ~1 KB | 512 KB - 1 MB |
| Limit na serwer | 1 000 000+ | 100 000+ | 1 000 000+ | 5 000 - 10 000 |
| Izolacja błędów | Pełna | Brak | Brak | Brak |
| GC per-process | Tak | Nie | Nie | Nie |
| Preemptive scheduling | Tak | Nie | Nie | Tak (OS) |
Java 21 wprowadziła virtual threads (Project Loom), rozwiązując problem skalowalności. Ale nie rozwiązuje problemu izolacji - exception w jednym wątku może uszkodzić współdzielony stan.
Problem nr 2: niezawodność
Niezawodność to nie jest „nice to have" w systemie, od którego zależy Twoja firma.
BEAM: 99.9999999% uptime
BEAM (maszyna wirtualna Erlanga/Elixira) został zaprojektowany przez Ericssona do systemów telekomunikacyjnych, gdzie minuta downtime'u = miliony utraconych połączeń. To nie jest framework webowy, do którego doklejono niezawodność - niezawodność jest fundamentem.
Co to oznacza w praktyce:
Izolacja procesów - każdy proces BEAM ma własną pamięć, własny garbage collector, własny stos. Crash jednego nie wpływa na żaden inny:
# Symulacja: 1 na 100 requestów powoduje błąd
defmodule OrderController do
def create(conn, params) do
# Jeśli to upadnie - tylko TEN request zwróci 500
# Pozostałe 99 requestów działa normalnie
# Proces zostaje zrestartowany w mikrosekundach
order = Orders.create!(params)
json(conn, order)
end
endSupervisor trees - hierarchia nadzorców, którzy automatycznie restartują procesy:
# Supervisor automatycznie restartuje uszkodzone procesy
children = [
{Phoenix.Endpoint, []}, # Web server
{MyApp.OrderProcessor, []}, # Przetwarzanie zamówień
{MyApp.NotificationSender, []}, # Powiadomienia
{MyApp.ReportScheduler, []} # Raporty cykliczne
]
# Jeśli NotificationSender padnie:
# 1. Supervisor to wykrywa (mikrosekundy)
# 2. Restartuje TYLKO NotificationSender
# 3. Web server, zamówienia i raporty działają bez przerwy
# 4. Log z błędem trafia do monitoringuCo to oznacza dla Go, Node.js i Java
| Scenariusz awarii | Elixir | Go | Node.js | Java |
|---|---|---|---|---|
| Nieobsłużony wyjątek | Restart procesu (~μs) | Panic → crash programu | Crash programu | Wątek umiera, stan niespójny |
| Memory leak w jednej funkcji | Izolowany do procesu | Wpływa na cały program | Wpływa na cały program | Wpływa na cały program |
| Nieskończona pętla | Scheduler przerywa po 4000 redukcji | Blokuje goroutine (i może wątek OS) | Blokuje cały event loop | Blokuje wątek |
| Potrzeba restartu komponentu | Hot restart przez supervisora | Restart całego programu | Restart całego programu | Restart programu (lub JRebel) |
| Aktualizacja bez downtime'u | Hot code reload | Blue-green deploy | Blue-green deploy | Rolling restart |
Problem nr 3: garbage collector
To temat, który programiści ignorują, a biznes odczuwa - bo to GC powoduje te losowe „zawieszenia" systemu.
Java - słoń w pokoju
Java ma prawdopodobnie najlepsze garbage collectory na świecie (ZGC, Shenandoah, G1). Problem w tym, że operują na całej pamięci procesu jednocześnie:
- Aplikacja Java z 8 GB heap → GC musi przejrzeć 8 GB
- Pauza GC: typowo 10-50 ms, ale zdarzają się piki 200-500 ms
- Użytkownik odczuwa to jako losowe „zacinanie się" systemu
Node.js - mniejszy heap, ale ten sam problem
V8 ogranicza heap do ~1.5 GB per proces. Mniejszy heap = krótsze pauzy GC (1-10 ms). Ale jeśli potrzebujesz więcej pamięci, musisz uruchomić wiele procesów - a to komplikuje współdzielenie stanu.
Go - bardzo dobre GC, ale globalne
Go ma jedne z najlepszych pausów GC w branży (< 1 ms). Ale nadal jest globalny - operuje na całym heapie programu.
Elixir/BEAM - GC, który nie istnieje (prawie)
BEAM rozwiązuje problem GC w sposób fundamentalnie inny: każdy proces ma własny garbage collector:
GC zbiera TYLKO pamięć P1 (kilka KB). P2, P3, P4 nie są zatrzymywane.
Rezultat:
- Pauza GC: < 1 ms - zawsze, niezależnie od ilości pamięci
- Brak „stop-the-world" - GC jednego procesu nie wpływa na inne
- Przewidywalna latencja - p99 bliskie p50
Dla systemu biznesowego to oznacza: brak losowych zawieszek, brak „system się przycina o 14:00 gdy wszyscy wracają z lunchu".
Problem nr 4: real-time i WebSocket
Nowoczesne systemy biznesowe potrzebują real-time: live dashboardy, powiadomienia, aktualizacje stanów. Jak to wygląda w każdej technologii:
Node.js + Socket.io
// Działa, ale skalowalność jest problemem
const io = require('socket.io')(server);
io.on('connection', (socket) => {
socket.join(`company:${socket.companyId}`);
socket.on('order:update', (data) => {
// Emituj do wszystkich w firmie
io.to(`company:${data.companyId}`).emit('order:changed', data);
});
});
// Problem: 10 000 połączeń = 10 000 eventów w JEDNYM wątku
// Potrzebujesz Redis Pub/Sub do wielu instancji
// Potrzebujesz sticky sessions na load balancerze
// Potrzebujesz osobnej infrastruktury dla WebSocketPhoenix LiveView - real-time bez JavaScriptu
# LiveView - real-time UI renderowany na serwerze
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
# Subskrybuj aktualizacje zamówień dla tej firmy
Phoenix.PubSub.subscribe(MyApp.PubSub, "company:#{socket.assigns.company_id}")
orders = Orders.list_today(socket.assigns.company_id)
{:ok, assign(socket, orders: orders, total: calculate_total(orders))}
end
# Gdy przyjdzie nowe zamówienie - UI aktualizuje się automatycznie
def handle_info({:order_created, order}, socket) do
orders = [order | socket.assigns.orders]
{:noreply, assign(socket, orders: orders, total: calculate_total(orders))}
end
# Zero JavaScriptu, zero Socket.io, zero Redisa
# BEAM zarządza 100 000+ połączeń na jednym serwerze
# PubSub działa w klastrze BEAM bez zewnętrznych zależności
end| Aspekt | Node.js + React + Socket.io | Phoenix LiveView |
|---|---|---|
| Pliki do napisania | Backend + Frontend + WebSocket | 1 plik LiveView |
| Zewnętrzne zależności | Redis, Socket.io | Brak |
| Połączenia na serwer | ~10 000 | ~100 000+ |
| Stan synchronizacji | Ręczny (Redux/React Query) | Automatyczny |
| SEO | Wymaga SSR | Wbudowany (server-rendered) |
| Czas implementacji | 3-5 dni | 0.5-1 dzień |
Problem nr 5: ekosystem i produktywność
„Ale Node.js ma npm z milionem paczek!"
Tak. I to jest jednocześnie siła i słabość:
node_modules dla typowego projektu Express:
├── 847 paczek
├── 234 MB na dysku
├── 12 paczek z known vulnerabilities
├── 3 paczki porzucone (ostatni commit 2+ lata temu)
└── left-pad incident (pamiętacie?)Elixir ma mniejszy ekosystem (~18 000 paczek na Hex vs ~2 000 000 na npm), ale:
- PostgreSQL zastępuje Redisa, Kafkę, MongoDB - mniej zależności od startu
- Oban zastępuje Bull/Sidekiq - kolejki w bazie, nie w osobnym serwisie
- Phoenix PubSub zastępuje Socket.io + Redis - wbudowany w framework
- LiveView zastępuje React/Vue + API - mniej kodu, mniej punktów awarii
| Funkcja | Node.js ecosystem | Elixir ecosystem |
|---|---|---|
| Web framework | Express + middleware | Phoenix (batteries included) |
| Real-time | Socket.io + Redis | Phoenix PubSub (wbudowany) |
| Background jobs | Bull + Redis | Oban (PostgreSQL) |
| WebSocket UI | React + Socket.io | LiveView (wbudowany) |
| Mailer | Nodemailer | Swoosh |
| Auth | Passport.js | mix phx.gen.auth |
| Łączna liczba zależności | 15-30 paczek | 5-10 paczek |
| Zewnętrzna infrastruktura | Redis, ewentualnie Kafka | Tylko PostgreSQL |
Mniej zależności = mniej podatności, mniej aktualizacji, mniej rzeczy, które mogą się zepsuć.
„Ale Java ma Spring Boot!"
Spring Boot to potężny framework. Ale porównajmy produktywność:
// Java/Spring - CRUD endpoint dla zamówień
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderMapper orderMapper;
@GetMapping
public ResponseEntity<Page<OrderDTO>> listOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Order> orders = orderRepository.findAll(
PageRequest.of(page, size, Sort.by("createdAt").descending())
);
return ResponseEntity.ok(orders.map(orderMapper::toDTO));
}
@PostMapping
@Transactional
public ResponseEntity<OrderDTO> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
Order order = orderMapper.toEntity(request);
order = orderRepository.save(order);
return ResponseEntity.status(HttpStatus.CREATED)
.body(orderMapper.toDTO(order));
}
}
// + OrderRepository.java (interface)
// + Order.java (entity, 80+ linii z JPA annotations)
// + OrderDTO.java (record/class)
// + CreateOrderRequest.java (record/class)
// + OrderMapper.java (MapStruct interface)
// = 6 plików, ~200 linii kodu# Elixir/Phoenix - to samo
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
def index(conn, params) do
page = Orders.list_orders(params)
render(conn, :index, page: page)
end
def create(conn, %{"order" => order_params}) do
case Orders.create_order(order_params) do
{:ok, order} ->
conn
|> put_status(:created)
|> render(:show, order: order)
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:error, changeset: changeset)
end
end
end
# + Order schema (Ecto, ~30 linii)
# + Orders context (~40 linii)
# = 3 pliki, ~80 linii kodu60% mniej kodu, mniej plików, mniej boilerplate'u. Przy 50 endpointach ta różnica to tygodnie zaoszczędzonego czasu.
„Ale Go jest szybszy!"
W raw throughput - tak. Go kompiluje się do kodu maszynowego, Elixir działa na maszynie wirtualnej. Benchmark „Hello World" Go wygra.
Ale systemy biznesowe to nie benchmarki „Hello World":
Typowy request w systemie biznesowym:
1. Parsuj request → 0.01 ms (nieistotne)
2. Zwaliduj dane → 0.1 ms (nieistotne)
3. Zapytanie do PostgreSQL → 2-15 ms (DOMINUJE)
4. Logika biznesowa → 0.5 ms (nieistotne)
5. Serializacja odpowiedzi → 0.05 ms (nieistotne)
─────────────────────────────────────
Łącznie: ~3-16 ms
Czy Go wykona krok 1 w 0.005 ms zamiast 0.01 ms?
Tak. Czy użytkownik to odczuje? Nie.95% czasu requestu to czekanie na bazę danych i I/O. Różnica w szybkości CPU między Go a Elixirem jest irrelewantna w typowym systemie biznesowym.
Gdzie Go naprawdę wygrywa:
- Narzędzia CLI (kompilacja do jednego binarki)
- Proxy / API gateway (czysty throughput)
- Kryptografia, kompresja, przetwarzanie binarne
Gdzie to nie ma znaczenia:
- Systemy CRUD
- Dashboardy i panele administracyjne
- Systemy z bazą danych jako bottleneck
Kiedy NIE wybralibyśmy Elixira
Uczciwie - Elixir nie jest najlepszym wyborem wszędzie:
| Scenariusz | Lepsza technologia | Dlaczego |
|---|---|---|
| Narzędzie CLI | Go / Rust | Jeden binary, brak runtime |
| Gra 3D, silnik graficzny | C++ / Rust | Wymagania CPU, brak GC |
| Aplikacja mobilna | Kotlin / Swift | Natywne SDK, UX |
| Data science, ML | Python | Ekosystem (NumPy, PyTorch) |
| System embedded, IoT | Rust / C | Brak runtime, bare metal |
| Prosty landing page | HTML/CSS / Next.js | Overkill na statyczną stronę |
Kiedy Elixir wygrywa
| Scenariusz | Dlaczego Elixir |
|---|---|
| System ERP/CRM | Współbieżność, niezawodność, LiveView |
| Platforma SaaS | Multi-tenant, izolacja, hot reload |
| System e-commerce | Real-time stany magazynowe, zamówienia |
| Dashboard / monitoring | LiveView, PubSub, zero Redisa |
| Platforma komunikacyjna | BEAM stworzony do telekomów |
| System integracyjny | GenServer, nadzór, retry, circuit breaker |
| System z wysokim SLA | Supervisor trees, hot code reload |
Porównanie kosztów utrzymania (3 lata)
Ostatecznie technologia to koszt. Policzmy TCO dla systemu z 50 użytkownikami:
| Składnik | Node.js + React | Java + Spring | Go + React | Elixir + LiveView |
|---|---|---|---|---|
| Serwery (infra) | 3 | 3-4 | 2 | 1 |
| Zewnętrzna infra (Redis, itp.) | Redis | Redis, ewentualnie Kafka | Redis | Brak |
| Developerzy do budowy | 3 (2 back + 1 front) | 3 (2 back + 1 front) | 3 (2 back + 1 front) | 2 (fullstack) |
| Koszt infrastruktury/mies. | ~4 500 PLN | ~5 500 PLN | ~3 500 PLN | ~2 000 PLN |
| Czas implementacji (MVP) | 4-5 mies. | 5-7 mies. | 4-5 mies. | 3-4 mies. |
| Linie kodu (szacunkowo) | ~40 000 | ~60 000 | ~35 000 | ~20 000 |
Mniej kodu = mniej bugów = mniej czasu na utrzymanie = niższy koszt.
| Koszt 3-letni | Node.js + React | Java + Spring | Go + React | Elixir + LiveView |
|---|---|---|---|---|
| Infrastruktura | 162 000 PLN | 198 000 PLN | 126 000 PLN | 72 000 PLN |
| Development (MVP) | 300 000 PLN | 420 000 PLN | 300 000 PLN | 200 000 PLN |
| Utrzymanie (rocznie) | 120 000 PLN | 150 000 PLN | 120 000 PLN | 80 000 PLN |
| Łącznie 3 lata | 822 000 PLN | 1 068 000 PLN | 786 000 PLN | 512 000 PLN |
Różnica między Elixirem a Javą: 556 000 PLN w 3 lata. To nie jest zaokrąglenie - to pół miliona złotych.
„Ale nie znajdę programistów Elixira!"
Najczęstsza obiekcja. Odpowiedź:
Nie musisz ich szukać. Zatrudniasz software house (jak nas), który ma zespół. Ale nawet gdybyś budował zespół wewnętrzny:
Programista Elixira uczy się języka w 2-4 tygodnie - jeśli zna dowolny język programowania. Elixir jest prosty składniowo.
Mniejszy zespół - tam, gdzie potrzebujesz 3 developerów Java + 1 frontend, wystarczą 2 osoby z Elixirem i LiveView.
Elixir przyciąga dobrych programistów - ludzie, którzy aktywnie wybierają mniej popularną technologię, zwykle są bardziej doświadczeni i zmotywowani.
Rynek rośnie - Stack Overflow Survey 2024: Elixir w top 5 „most loved languages" szósty rok z rzędu. Programiści chcą w nim pisać.
Ekosystem Erlanga - BEAM to nie nisza. WhatsApp (2 mld użytkowników, 50 inżynierów), Discord (150 mln użytkowników), RabbitMQ, Riak, CouchDB - wszyscy na BEAM.
Podsumowanie: kiedy co wybrać
Potrzebujesz systemu biznesowego 24/7?
├── Tak → Elixir (niezawodność, supervisor trees, hot reload)
│
Potrzebujesz real-time UI?
├── Tak → Elixir + LiveView (zero dodatkowej infrastruktury)
│
Budujesz narzędzie CLI lub proxy?
├── Tak → Go (jeden binary, raw speed)
│
Masz istniejący zespół Java i korporacyjne wymagania?
├── Tak → Java/Spring (znany stack, enterprise support)
│
Budujesz prosty CRUD z minimalnym budżetem?
├── Tak → Node.js (najłatwiej znaleźć developerów)
│
Budujesz system, który musi działać latami bez przepisywania?
└── Elixir (BEAM działa od 1986, backward compatibility)Nie wybieramy Elixira, bo jest modny. Wybieramy go, bo dla systemów biznesowych nie ma lepszego narzędzia. Mniej serwerów, mniej kodu, mniej awarii, mniej kosztów. Liczby mówią same za siebie.
Chcesz zobaczyć, jak Elixir sprawdzi się w Twoim projekcie? Porozmawiajmy - pokażemy konkretne kalkulacje dla Twojego przypadku.