Thread pools and async
How to size a thread pool, pick its queue and rejection policy, avoid pool starvation, and when async/await, virtual threads or goroutines beat a pool. Demos and code in five languages.
- Read first
- Producer-consumer
A web server that starts a new OS thread for every request works fine in testing. Then traffic spikes to 20,000 concurrent requests. Each thread reserves about 1 MB of stack, so that’s 20 GB of address space. The OS spends more time switching between threads than running them. The process dies, or crawls.
The fix most servers have used for 25 years is a thread pool: a fixed set of threads that take tasks from a queue. It’s the producer-consumer pattern, with the consumers built in.
Pools raise their own questions. How many threads? How big a queue? What to do when both are full? And for work that mostly waits on I/O, there’s a newer answer that avoids the pool’s limits entirely. One option is async/await on an event loop. Another is very cheap threads (goroutines, Java’s virtual threads) that the runtime schedules itself. This page covers when to use which.
Thread pools
Section titled “Thread pools”A pool has three parts, each a design decision:
- Workers: N long-lived threads, each looping “take a task, run it”.
- A queue of submitted tasks waiting for a free worker.
- A rejection policy for when the queue is full. It can block the submitter, run the task on the submitter’s thread, reject with an error, or drop the task.
Submitting returns a future. It’s a handle the caller can wait on, for the result or the task’s exception. Shutting down means “accept nothing new, finish what’s queued”. Then, after a deadline, it interrupts whatever is still running.
import java.util.ArrayList;import java.util.List;import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.Future;import java.util.concurrent.ThreadPoolExecutor;import java.util.concurrent.TimeUnit;
// 100 CPU-bound jobs on a pool sized to the machine, with a bounded queue and a clear policy// for overload, then a clean shutdown.public class Main { public static void main(String[] args) throws Exception { int cores = Runtime.getRuntime().availableProcessors(); // Built by hand instead of Executors.newFixedThreadPool, whose queue is unbounded: // under overload it would hold every submitted task in memory. ThreadPoolExecutor pool = new ThreadPoolExecutor( cores, cores, // CPU-bound work: one thread per core 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(cores * 4), // a bounded backlog // When the queue is full, the submitting thread runs the task itself. That slows // the producer down to the pool's pace: backpressure without dropping work. new ThreadPoolExecutor.CallerRunsPolicy());
List<Future<Long>> results = new ArrayList<>(); for (int i = 1; i <= 100; i++) { final int n = i; results.add(pool.submit(() -> sumOfSquares(n * 10_000))); // a Future per job } long total = 0; for (Future<Long> f : results) total += f.get(); // get() waits and rethrows a job's exception
pool.shutdown(); // stop accepting work; queued jobs still run if (!pool.awaitTermination(10, TimeUnit.SECONDS)) pool.shutdownNow(); // then interrupt stragglers if (total != expected()) throw new AssertionError("wrong total"); System.out.println("100 jobs on " + cores + " threads, total = " + total); }
static long sumOfSquares(int n) { long s = 0; for (long i = 1; i <= n; i++) s += i * i % 7; return s; }
static long expected() { long t = 0; for (int i = 1; i <= 100; i++) t += sumOfSquares(i * 10_000); return t; }}import osfrom concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def sum_of_squares(n: int) -> int: return sum(i * i % 7 for i in range(1, n + 1))
if __name__ == "__main__": jobs = [i * 1_000 for i in range(1, 101)] expected = sum(sum_of_squares(n) for n in jobs)
# CPU-bound work: threads won't help, because only one thread runs Python bytecode at a # time (the GIL). A process pool runs one interpreter per core instead. Each job and result # is pickled across a process boundary, so give each job enough work to be worth it. with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool: total = sum(pool.map(sum_of_squares, jobs, chunksize=4)) # Leaving the `with` block calls shutdown(wait=True): no new work, finish what's queued. assert total == expected
# I/O-bound work (HTTP calls, database queries) is where a thread pool fits: threads release # the GIL while they wait. Note the executor's queue is unbounded; submitting a million jobs # holds a million futures in memory. Bound it yourself (a semaphore) under real load. with ThreadPoolExecutor(max_workers=16) as pool: futures = [pool.submit(sum_of_squares, n) for n in jobs[:10]] assert sum(f.result() for f in futures) == sum(sum_of_squares(n) for n in jobs[:10])
print(f"100 jobs on {os.cpu_count()} processes, total = {total}")using System;using System.Linq;using System.Threading;using System.Threading.Tasks;
// 100 CPU-bound jobs with at most one per core running at a time, then the same limit applied// to async I/O, where no thread is held while waiting.static class Program{ static long SumOfSquares(int n) { long s = 0; for (long i = 1; i <= n; i++) s += i * i % 7; return s; }
static async Task Main() { int cores = Environment.ProcessorCount; var jobs = Enumerable.Range(1, 100).Select(i => i * 10_000).ToArray(); long expected = jobs.Sum(SumOfSquares);
// CPU-bound: Parallel.For runs on the shared thread pool, at most one worker per core. long total = 0; Parallel.For(0, jobs.Length, new ParallelOptions { MaxDegreeOfParallelism = cores }, i => Interlocked.Add(ref total, SumOfSquares(jobs[i]))); if (total != expected) throw new InvalidOperationException("wrong total");
// I/O-bound: ForEachAsync limits how many run at once; while each awaits, it holds no // thread, so 16 concurrent calls might use only one or two pool threads. long ioTotal = 0; await Parallel.ForEachAsync(jobs, new ParallelOptions { MaxDegreeOfParallelism = 16 }, async (n, ct) => { await Task.Delay(1, ct); // the network call Interlocked.Add(ref ioTotal, SumOfSquares(n)); }); if (ioTotal != expected) throw new InvalidOperationException("wrong total"); Console.WriteLine($"100 jobs on {cores} cores, total = {total}"); }}package main
import ( "fmt" "runtime" "sync")
func sumOfSquares(n int) int64 { var s int64 for i := int64(1); i <= int64(n); i++ { s += i * i % 7 } return s}
// 100 CPU-bound jobs on a fixed set of workers reading from one channel: Go's thread pool.// Goroutines are cheap enough to start one per job, but a fixed set bounds memory and CPU, and// the channel gives backpressure when the workers fall behind.func main() { workers := runtime.NumCPU() // CPU-bound: one worker per core type job struct{ index, n int } jobs := make(chan job, workers*4) // bounded backlog results := make([]int64, 100) // each job writes only its own slot
var wg sync.WaitGroup for range workers { wg.Add(1) go func() { defer wg.Done() for j := range jobs { results[j.index] = sumOfSquares(j.n) } }() } for i := range 100 { jobs <- job{i, (i + 1) * 10_000} // blocks while the backlog is full } close(jobs) // no more jobs: workers drain the channel and exit wg.Wait() // after Wait, every write to results is visible here
var total, expected int64 for i, r := range results { total += r expected += sumOfSquares((i + 1) * 10_000) } if total != expected { panic("wrong total") } fmt.Printf("100 jobs on %d workers, total = %d\n", workers, total)}// Node runs JavaScript on one thread, so a "pool" for I/O is just a cap on how many async tasks// are in flight at once. (CPU-bound work needs worker_threads, or it blocks the event loop.)async function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { const results = new Array<R>(items.length); let next = 0; // `limit` workers, each pulling the next index until none are left. Reading and bumping // `next` happens with no await in between, so two workers never take the same item. const worker = async () => { while (next < items.length) { const i = next++; results[i] = await fn(items[i]); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return results;}
let inFlight = 0;let peak = 0;
async function fetchPrice(id: number): Promise<number> { peak = Math.max(peak, ++inFlight); await new Promise((resolve) => setTimeout(resolve, 5)); // the network call inFlight--; return id * 2;}
async function main(): Promise<void> { const ids = Array.from({ length: 100 }, (_, i) => i + 1); const prices = await mapWithLimit(ids, 8, fetchPrice); const total = prices.reduce((a, b) => a + b, 0); if (total !== 10_100 || peak > 8) throw new Error(`total ${total}, peak ${peak}`); console.log(`100 calls, at most ${peak} in flight, total = ${total}`); // at most 8 in flight, total = 10100}
main();What each version shows:
- Java:
ThreadPoolExecutor, built by hand.Executors.newFixedThreadPool(n)looks convenient, but has an unbounded queue. AndnewCachedThreadPool()has unbounded threads. Under overload, one exhausts memory and the other exhausts the OS.CallerRunsPolicymakes a submitter run the task itself when the pool is full. That slows the producer down to the pool’s pace. - Python: for CPU-bound work, use a process pool, because the GIL lets only one thread run
Python code at a time. Threads still help with I/O, since they release the GIL while waiting.
ThreadPoolExecutor’s queue is unbounded, so bound submissions yourself under real load. - C#: you rarely make a pool, because the runtime has one.
Parallel.Forcaps CPU-bound parallelism.Parallel.ForEachAsynccaps async I/O, where a waiting call holds no thread at all. - Go: goroutines are cheap enough to start one per job. But a fixed set of workers reading a buffered channel bounds CPU and memory, and gives backpressure. Closing the channel is the shutdown.
- TypeScript: one thread runs all JavaScript. So a “pool” for I/O is a cap on promises in
flight. CPU-bound work needs
worker_threads, or it blocks everything.
How many threads?
Section titled “How many threads?”CPU-bound work (resizing images, compressing, hashing) keeps a core busy the whole time. More
threads than cores just take turns on the same cores, and add context switches. Use about one thread
per core (availableProcessors(), NumCPU()). Maybe add one to cover the odd page fault.
I/O-bound work (HTTP calls, database queries) spends most of its time waiting. A waiting thread uses no CPU. There are two ways to size it:
- From the ratio of waiting to computing: threads ≈ cores × (1 + wait time / compute time). Say a request computes for 5 ms and waits 45 ms on the database. On 8 cores, it needs about 8 × (1 + 9) = 80 threads to keep the CPUs busy.
- From the load you must carry, with Little’s law: tasks in flight = arrival rate × time each task takes. At 2,000 requests a second and 50 ms per request, 2,000 × 0.05 = 100 requests are in flight on average. So you need at least 100 threads, plus headroom for bursts.
Then check the resource behind the pool. Say 100 threads all query a database with a 20-connection pool. Then 80 threads are waiting on the connection pool. The smallest limit in the chain sets the real concurrency, so size pools together.
Our service calls a slow downstream API. Should we raise the thread pool from 50 to 500?
Whether you size from numbers (Little’s law: rate times latency), and think about what’s behind the pool. The strong answer asks for the request rate and the downstream latency. It notes that 500 threads also means 500 concurrent calls to a service that’s already slow. And it suggests a timeout, a bulkhead (a separate small pool for that API) and async I/O. The red flag is “Yes, more threads means more throughput”.
Pool starvation: a deadlock without locks
Section titled “Pool starvation: a deadlock without locks”A task on a pool submits a child task to the same pool, and waits for the child’s result. Say every pool thread is a parent doing the same. Then all of them wait for children that sit in the queue, with no thread to run them. Nothing holds a lock, and yet nothing can move. Step through it with a pool of one, then two:
- run parent task
- pool.submit(child)
- child.get() // block until done
- finish parent task
ready
A task on a thread pool submits a child task to the same pool and blocks until the child finishes. Step the threads and see whether the child ever runs.
A pool of two only moves the cliff: two parents at once hang it the same way. The fixes:
- Don’t block a pool thread on work queued to the same pool. Compose instead:
thenComposein Java,awaitin C# and Python. Then the parent gives its thread back while it waits. Java’sForkJoinPoolis built for this. A joining thread runs other queued tasks, instead of sleeping. - Separate pools for separate kinds of work. This is the bulkhead pattern. One pool serves requests, another the slow downstream call, another background jobs. A flood of one kind can’t use up the threads the others need.
Go deeperThreadLocal and context leaks in pools2 min
Pool threads live forever, and run thousands of unrelated tasks. Anything stored per thread survives
from one task into the next, unless it’s cleared. Examples are Java’s ThreadLocal, a request id in
a logging context, or the current user. The classic bugs: a request sees the previous request’s
user. Or a ThreadLocal holding a large buffer keeps it alive for the pool’s lifetime.
Clear thread-local state in a finally at the end of each task, or pass context explicitly. Java’s
scoped values (standardized in recent releases), C#’s AsyncLocal<T>, Python’s contextvars and
Go’s context.Context all scope context to a task rather than a thread. That’s the model to prefer.
Async and event loops
Section titled “Async and event loops”A thread that waits 50 ms for a database holds about 1 MB of stack and an OS thread the whole time,
doing nothing. Async/await removes the waiting thread. When a function reaches await on I/O,
it returns its thread to the runtime. The runtime resumes it later, when the I/O completes, on
whatever thread is free.
In Node and Python’s asyncio, there’s exactly one thread, running an event loop. The loop takes the next ready callback, runs it until it awaits, and repeats.
- Color key
- Database
Here are 10,000 requests that each wait 100 ms, in each language’s lightweight style. A pool of 200 OS threads would need 5 seconds. Each of these finishes in about a tenth of a second:
import java.time.Duration;import java.util.concurrent.Executors;
// 10,000 requests that each wait 100 ms on I/O. With a pool of 200 platform threads this takes// 10,000 / 200 x 100 ms = 5 s. With virtual threads (Java 21) it takes about 100 ms.public class Main { public static void main(String[] args) throws Exception { long start = System.nanoTime(); // One cheap virtual thread per task. When a virtual thread blocks on sleep, a socket or a // lock, the JVM parks it and reuses its carrier OS thread for another virtual thread. try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); // stands in for a database call return null; }); } } // close() waits for every task to finish long ms = (System.nanoTime() - start) / 1_000_000; if (ms > 3_000) throw new AssertionError("took " + ms + " ms"); System.out.println("10000 waits of 100 ms finished in well under a second of wall time"); }}import asyncioimport time
async def handle(i: int) -> int: # `await` hands control back to the event loop while this request waits, so one thread can # have thousands of requests waiting at once. Code between awaits runs without interruption. await asyncio.sleep(0.1) # stands in for a database call return i
async def main() -> None: start = time.perf_counter() # 10,000 requests that each wait 100 ms. A pool of 200 threads would need 5 s. results = await asyncio.gather(*(handle(i) for i in range(10_000))) elapsed = time.perf_counter() - start assert len(results) == 10_000 and elapsed < 3, elapsed print("10000 waits of 100 ms finished in well under a second of wall time")
if __name__ == "__main__": asyncio.run(main())using System;using System.Diagnostics;using System.Linq;using System.Threading.Tasks;
// 10,000 requests that each wait 100 ms on I/O. With 200 blocked threads this takes 5 s; with// async/await it takes about 100 ms, because a waiting request holds no thread.static class Program{ static async Task<int> HandleAsync(int i) { // await returns the thread to the pool while the I/O is pending; the method resumes on // any pool thread when it completes. (Thread.Sleep here would hold a thread instead.) await Task.Delay(100); // stands in for a database call return i; }
static async Task Main() { var clock = Stopwatch.StartNew(); int[] results = await Task.WhenAll(Enumerable.Range(0, 10_000).Select(HandleAsync)); if (results.Length != 10_000 || clock.ElapsedMilliseconds > 3_000) throw new InvalidOperationException($"took {clock.ElapsedMilliseconds} ms"); Console.WriteLine("10000 waits of 100 ms finished in well under a second of wall time"); }}package main
import ( "fmt" "sync" "time")
// 10,000 requests that each wait 100 ms on I/O. Goroutines start with a few KB of stack, and// the Go scheduler parks a blocked goroutine and runs another on the same OS thread, so plain// blocking code scales like async code. There is no separate async style in Go.func main() { start := time.Now() var wg sync.WaitGroup for range 10_000 { wg.Add(1) go func() { defer wg.Done() time.Sleep(100 * time.Millisecond) // stands in for a database call }() } wg.Wait() if elapsed := time.Since(start); elapsed > 3*time.Second { panic(fmt.Sprint("took ", elapsed)) } fmt.Println("10000 waits of 100 ms finished in well under a second of wall time")}// 10,000 requests that each wait 100 ms. Node's one thread starts every request, and each// `await` hands the thread back to the event loop until the I/O completes.const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
async function handle(i: number): Promise<number> { await sleep(100); // stands in for a database call return i;}
async function main(): Promise<void> { const start = performance.now(); const results = await Promise.all(Array.from({ length: 10_000 }, (_, i) => handle(i))); const elapsed = performance.now() - start; if (results.length !== 10_000 || elapsed > 3_000) throw new Error(`took ${elapsed} ms`);
// The other side of one thread: CPU work between awaits blocks every request. This loop // delays the 0 ms timer by its full 200 ms. const timerAsked = performance.now(); const timer = new Promise<number>((resolve) => setTimeout(() => resolve(performance.now() - timerAsked), 0)); const until = performance.now() + 200; while (performance.now() < until) {} // e.g. parsing a huge JSON body, a sync crypto call const lateBy = await timer; if (lateBy < 200) throw new Error("expected the loop to block the timer"); console.log("10000 waits of 100 ms finished in well under a second of wall time");}
main();- Java 21 virtual threads: plain blocking code (
Thread.sleep, JDBC, sockets). But each task gets a virtual thread costing a few KB. When it blocks, the JVM parks it, and reuses the OS thread underneath. There’s noasynckeyword, and no colored functions. - Python
asyncio.gather: one thread, one event loop, andawaitat every I/O point. - C#
async/awaitwithTask.WhenAll: the awaits hold no thread, and continuations run on the shared pool. - Go: there is no separate async style. Every goroutine is lightweight. The scheduler parks a blocked goroutine, and runs another on the same OS thread. So blocking code scales like async code.
- TypeScript: Node’s event loop. The second half of the example shows the catch. A 200 ms CPU loop delays a 0 ms timer by the full 200 ms, because nothing else runs until the loop ends. Every request on the server waits behind it.
Async code still races
Section titled “Async code still races”One thread doesn’t mean no races. Between two awaits, code runs without interruption. But at each await, any other ready task can run. Say a function reads a balance, awaits, then writes based on what it read. That has exactly the check-then-act gap from the race conditions page. Each step below runs a request until its next await. Play 1, 2, 1, 2:
- const b = await db.getBalance()
- if (b >= 100) await db.setBalance(b - 100)
ready
- const b = await db.getBalance()
- if (b >= 100) await db.setBalance(b - 100)
ready
One Node.js thread serves two withdrawal requests for 100 from a balance of 100. A step runs a request until its next await. Try 1, 2, 1, 2.
The fixes are the same ideas as with threads:
- Keep the read and the write in one synchronous run.
- Push the check-and-write into one atomic operation in the database
(
UPDATE ... WHERE balance >= 100). - Or serialize the critical section with an async lock.
Choosing a model
Section titled “Choosing a model”| Work | Java | Python | C# | Go | TypeScript |
|---|---|---|---|---|---|
| CPU-bound, parallel | Fixed pool, one thread per core | ProcessPoolExecutor |
Parallel.For, the thread pool |
Worker goroutines, one per core | worker_threads |
| Many concurrent I/O waits | Virtual threads | asyncio |
async/await |
A goroutine per request | async/await (the default) |
| Limit calls to one dependency | Semaphore, or a small separate pool |
asyncio.Semaphore |
SemaphoreSlim |
Buffered channel | A promise limiter |
| A few blocking calls in an async app | Not needed with virtual threads | asyncio.to_thread |
Task.Run, sparingly |
Not needed | worker_threads or a child process |
Go deeperVirtual threads: what's still a trap2 min
Virtual threads make blocking cheap, but they still have rules. In Java 21 to 23, a virtual thread
that blocks inside a synchronized block is pinned to its carrier OS thread. That carrier then
can’t run other virtual threads. With enough of them, the carrier pool is used up. (Java 24 removed
most pinning.) Replacing synchronized with ReentrantLock around blocking calls avoided it.
Pooling virtual threads is also a mistake. They’re meant to be created per task and thrown away. To
limit concurrency against a database, use a Semaphore around the call, not a pool of virtual
threads. And ThreadLocal caches built for a few hundred pool threads become a million copies with
a million virtual threads.
These calls are I/O-bound. At 50 ms each and 2,000 a second, Little’s law gives about 100 in flight. I’d use async I/O or virtual threads, so waiting costs nothing. I’d cap concurrency to the database at its connection limit, with a semaphore. And I’d keep CPU-heavy work on a separate pool sized to the cores.
How pools and async fail
Section titled “How pools and async fail”| Failure | Symptom | Prevention |
|---|---|---|
| Unbounded queue | Memory climbs during an outage downstream; latency in minutes | Bounded queue and a rejection policy |
| Unbounded threads | Thousands of threads, the OS thrashes, out-of-memory errors | A fixed maximum; or virtual threads, which are cheap |
| Pool starvation | Every worker blocked, CPU idle, queue growing | Never block on the same pool; separate pools |
| Blocking the event loop | Every request’s latency jumps together | Move CPU work to workers; no sync I/O in async code |
| Exceptions lost in futures | A failed task leaves no trace | Always get()/await the future, or attach a handler |
| No shutdown | The process hangs on exit, or drops queued work | shutdown, wait with a deadline, then cancel |
Interview questions
Section titled “Interview questions”Interviewer asksHow do you choose the size of a thread pool?
By the kind of work. For CPU-bound work, use about the number of cores, since more threads only add switching. For I/O-bound work, use the wait-to-compute ratio (cores × (1 + wait / compute)). Or use Little’s law: arrival rate × task duration gives the number in flight. Add headroom. Then check what the pool calls. There’s no point running more concurrent queries than the database pool allows. Finally, measure under load and adjust.
Interviewer asksWhat happens when a ThreadPoolExecutor's queue is full?
First it adds threads, up to maximumPoolSize. (Threads beyond the core size are only created when
the queue is full.) Then it applies the rejection policy:
AbortPolicythrowsRejectedExecutionException. This is the default.CallerRunsPolicyruns the task on the submitting thread.DiscardPolicydrops it silently.DiscardOldestPolicydrops the oldest queued task.
Choose on purpose. CallerRunsPolicy gives natural backpressure. AbortPolicy gives fast failure
that you can turn into a 503.
Interviewer asksIs async/await faster than threads?
Not for any single request, and not for CPU-bound work. It’s cheaper per waiting request: no thread, no stack, no context switch. So one process can hold far more concurrent I/O waits. That raises throughput when the bottleneck is the number of threads. If the bottleneck is CPU or a downstream service, async changes nothing.
Interviewer asksVirtual threads or async/await for a new Java service?
Virtual threads, in most cases. They give the same scalability for I/O-bound work. And they keep plain blocking code, readable stack traces and existing libraries (JDBC, HTTP clients). Async or reactive styles still make sense for streaming with backpressure, or where a framework is already built on them. Watch for pinning on older JDKs, and don’t pool virtual threads.
Interviewer asksWhy can a Node.js server become slow for every user at once?
Because all JavaScript runs on one thread. Any CPU-heavy synchronous work blocks the event loop.
That could be parsing a huge JSON body, a sync crypto or compression call, a slow regex, or a big
loop. Every other request, timer and callback waits behind it. To fix it, move CPU work to
worker_threads or another service, use async APIs, and monitor event loop lag.
| Idea | Remember |
|---|---|
| Thread pool | Fixed workers plus a queue: producer-consumer with the consumers built in |
| Bounded everything | Bounded threads, bounded queue, explicit rejection policy |
| CPU-bound sizing | About one thread per core |
| I/O-bound sizing | Little’s law: in flight = rate × duration; respect downstream limits |
| Pool starvation | Tasks waiting on tasks in the same pool; compose instead, use bulkheads |
| Async/await | A waiting task holds no thread; one blocking call stalls the loop |
| Races across await | Every await is an interleaving point; check-then-act still breaks |
| Virtual threads, goroutines | Blocking code at async scale; no function coloring |
Further reading
Section titled “Further reading”- Brian Goetz et al., Java Concurrency in Practice, chapters 6 and 8 (task execution and thread pools), including the sizing formula.
- JEP 444: Virtual Threads.
- Don’t block the event loop, from the Node.js docs.
- Back to the concurrency track, or apply all of this to a design with the LLD problems, each of which ends with a concurrency section.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.