Typst Concurrent Renderer
Project Overview:
This project is a code-to-PDF engine designed to render documents at scale with predictable performance. It uses Typst, which is fast and can compile directly from stdin to stdout, avoiding the heavy overhead of spinning up headless Chrome for HTML-to-PDF conversion. The goal is to maximize rendering throughput while preventing the server from being overwhelmed by child processes.
Bypassing File I/O
The default behavior of CLI tools often involves writing an input file, pointing the CLI to it and writing an output file. At scale, this creates an I/O bottleneck.
To solve this, the application controls stdin and stdout directly, utilizing memory buffers for input and output to entirely bypass disk I/O during the render step.
cmd := exec.Command("typst", "compile", "-", "-")
cmd.Stdin = strings.NewReader(source)
out, err := cmd.Output()
The Concurrency Problem
While the code above works for a single PDF, scaling it to 1000 concurrent requests exposes a critical issue: mapping one request to one goroutine, therefore one OS-level typst process.
Process overhead is significantly higher than goroutine overhead. Starting 1000 OS processes simultaneously leads to heavy context switching, memory spikes and CPU scheduling noise.
The Solution: A Fixed Worker Pool
I implemented a fixed Go worker pool around the Typst CLI.
- Callers send Typst source code into a buffered input channel.
- A fixed number of workers read from that channel.
- Each worker executes
typst compile - -. - Successful renders are sent to
OutputChan. - Failures are sent to
ErrorChan.
type Renderer struct {
wg sync.WaitGroup
inputChan chan string
OutputChan chan Pdf
ErrorChan chan error
processTimeout time.Duration
}
Initializing Workers
Workers are initialized once. By allocating workers based on CPU core count, the system ensures that at most N renders happen simultaneously. Excess requests queue in the input channel buffer, create backpressure.
func New(obj RendererNew) *Renderer {
processTimeout := obj.ProcessTimeout
if processTimeout == 0 {
processTimeout = 5 * time.Second
}
r := &Renderer{
inputChan: make(chan string, obj.InputChanSize),
OutputChan: make(chan Pdf, obj.OutputChanSize),
ErrorChan: make(chan error, obj.OutputChanSize),
processTimeout: processTimeout,
}
for i := 0; i < obj.Workers; i++ {
go r.startInternalWorker()
}
return r
}
The Worker Loop
Each worker strictly processes one document at a time.
func (r *Renderer) startInternalWorker() {
for source := range r.inputChan {
pdfBinary, err := r.processTypst(source)
if err != nil {
r.ErrorChan <- err
} else {
r.OutputChan <- pdfBinary
}
r.wg.Done()
}
}
Process Timeouts & Error Handling
A stuck process can permanently lock a worker. To prevent this, every Typst execution is wrapped in a context timeout. The system also captures stderr to preserve Typst’s internal compilation errors.
func (r *Renderer) processTypst(s string) (Pdf, error) {
ctx, cancel := context.WithTimeout(context.Background(), r.processTimeout)
defer cancel()
var stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "typst", "compile", "-", "-")
cmd.Stdin = strings.NewReader(s)
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("typst timed out after %s: %w", r.processTimeout, ctx.Err())
}
return nil, fmt.Errorf("typst err: %v, stderr: %s", err, stderr.String())
}
return out, nil
}
Draining the Channels
Because this relies on a channel based API, the caller must actively drain both OutputChan and ErrorChan. Failing to read from these channels will cause the renderer to block once the output buffers fill up.
go func() {
defer listenerWg.Done()
for range r.OutputChan {
successCount++
}
}()
go func() {
defer listenerWg.Done()
for errVal := range r.ErrorChan {
errorCount++
if errorCount <= 5 {
fmt.Printf("[Error] %v\n", errVal)
}
}
}()
Configuration Choices
Because Typst compilation is heavily CPU-bound, the worker count defaults to the machine’s logical CPU cores.
workers := runtime.NumCPU()
bufferSize := workers * 2
Scaling workers beyond CPU count introduces context-switching overhead without improving throughput. The channel buffer is kept small to decouple the producer from the fixed worker pool without masking downstream bottlenecks.
Out of Scope
To keep the service focused strictly on rendering, the following were excluded:
- HTTP/gRPC transport layers
- Permanent storage (S3, Databases)
- Advanced queuing systems (e.g., RabbitMQ, Kafka)
Results
On a machine with 12 CPU threads, the pool processed 1000 generated documents using 12 workers and a buffer size of 24. The architecture successfully saturated the CPU without unbounded process spawning.
Starting stress test: 1000 runs | 12 workers | 24 buffer
=====================================
Total Time taken : 11.077930344s
Avg Time taken : 11.00 ms
Success Rate : 1000/1000
Error Rate : 0/1000
Throughput : 90.27 PDFs/sec
=====================================