DEV Community

Cover image for How to Speed Up Large Go Project Builds by 90% with Custom Build Systems
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How to Speed Up Large Go Project Builds by 90% with Custom Build Systems

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Let me tell you about a problem that slowly creeps up on Go projects. You start with a simple codebase that builds in seconds. Everything feels fast and responsive. Then your team grows. Your project expands to hundreds, then thousands of packages. One day you run go build and go make coffee. When you return, it's still building. This is the reality for many large Go projects, and I've lived through this frustration.

The standard Go toolchain works beautifully for small to medium projects. But when you cross a certain threshold, those incremental builds that used to take seconds now take minutes. The cache doesn't seem to help as much. Your team's productivity drops as everyone waits for compilation. This isn't a flaw in Go's design—it's just that the standard tools aren't optimized for massive codebases with complex dependency graphs.

I want to show you what happens when you build a custom solution. The implementation I'll share isn't theoretical. It's based on solving real problems in production environments where build times had become unacceptable. We managed to reduce compilation times by 70-90% in some cases. The difference feels like upgrading from a bicycle to a sports car.

The core issue with large projects is that standard go build re-evaluates everything each time. Even with Go's built-in cache, there's overhead in checking what needs rebuilding. When you have thousands of packages with complex interdependencies, this checking process itself takes time. Then there's the actual compilation, which happens mostly sequentially.

Our solution breaks this process into distinct, optimized components. Think of it as having a head chef in a busy kitchen instead of every cook trying to do everything. The head chef knows exactly what needs preparation, what's already cooked, and who should work on what. This coordination eliminates wasted effort.

Let me start with the foundation—the BuildSystem struct that orchestrates everything. This isn't just a wrapper around go build. It's a complete manager that understands your project's structure.

type BuildSystem struct {
    cache         *ArtifactCache
    depGraph      *DependencyGraph
    buildPool     *BuildPool
    config        BuildConfig
    stats         BuildStats
}
Enter fullscreen mode Exit fullscreen mode

Each component has a specific job. The cache stores compiled packages so we don't rebuild them unnecessarily. The dependency graph understands how packages connect to each other. The build pool manages concurrent compilation. They work together like parts of a well-oiled machine.

The dependency analysis is where the magic begins. Most build systems check timestamps on files. We go further by calculating cryptographic hashes of the actual content. This catches more subtle changes and gives us a precise fingerprint of each package's state.

func (bs *BuildSystem) calculatePackageHash(info PackageInfo) (string, error) {
    h := sha256.New()

    // Include package metadata
    fmt.Fprintf(h, "Package:%s\n", info.ImportPath)
    fmt.Fprintf(h, "GoFiles:%v\n", info.GoFiles)
    fmt.Fprintf(h, "Imports:%v\n", info.Imports)
    fmt.Fprintf(h, "Deps:%v\n", info.Deps)

    // Include file contents
    for _, file := range info.GoFiles {
        filePath := filepath.Join(info.Dir, file)
        content, err := os.ReadFile(filePath)
        if err != nil {
            return "", err
        }
        h.Write(content)
    }

    return hex.EncodeToString(h.Sum(nil)), nil
}
Enter fullscreen mode Exit fullscreen mode

Why hash the content instead of just checking modification times? Two reasons. First, Git operations and some editors can modify timestamps without changing content. Second, we want to detect when files change back to previous states. If a file returns to its original content, we can reuse the cached artifact.

The dependency graph builds a complete map of your project. It knows not just what each package imports, but what packages depend on it. This bidirectional awareness is crucial. When package A changes, we need to know that packages B, C, and D use A and therefore need rebuilding.

type DependencyGraph struct {
    mu          sync.RWMutex
    packages    map[string]*PackageInfo
    dependents  map[string][]string
    changed     map[string]bool
}
Enter fullscreen mode Exit fullscreen mode

The dependents map is what makes this efficient. Standard tools can tell you what a package imports. Our system also tracks the reverse—what packages import this one. When we detect a change in package X, we immediately know which packages to rebuild without traversing the entire graph.

Identifying changed packages becomes a targeted operation. Instead of checking every file in your project, we check only what's necessary. If nothing has changed since the last build, we return immediately with cached results.

func (bs *BuildSystem) identifyChangedPackages() []string {
    bs.depGraph.mu.RLock()
    defer bs.depGraph.mu.RUnlock()

    var changed []string

    for pkg, info := range bs.depGraph.packages {
        cacheKey := bs.generateCacheKey(pkg, info)

        // Check cache
        cached, err := bs.cache.Get(cacheKey)
        if err != nil || cached == nil {
            bs.depGraph.changed[pkg] = true
            changed = append(changed, pkg)
            continue
        }

        // Check if dependencies changed
        depsChanged := false
        for _, dep := range info.Imports {
            if bs.depGraph.changed[dep] {
                depsChanged = true
                break
            }
        }

        if depsChanged {
            bs.depGraph.changed[pkg] = true
            changed = append(changed, pkg)
        }
    }

    return changed
}
Enter fullscreen mode Exit fullscreen mode

Notice how we check both the cache and dependency changes. A package might not have changed itself, but if something it depends on changed, it still needs rebuilding. This propagation ensures correctness while minimizing work.

Now let's talk about the cache. This isn't just storing files on disk. It's a content-addressable storage system where the key is derived from the content. Identical compilation results produce identical keys, regardless of when or where they were built.

func (ac *ArtifactCache) Get(key string) (*CacheEntry, error) {
    ac.mu.RLock()
    entry, exists := ac.artifacts[key]
    ac.mu.RUnlock()

    if exists && time.Since(entry.Timestamp) < 24*time.Hour {
        atomic.AddUint64(&ac.hits, 1)
        return entry, nil
    }

    // Try storage backend
    entry, err := ac.storage.Load(key)
    if err == nil && entry != nil {
        ac.mu.Lock()
        ac.artifacts[key] = entry
        ac.mu.Unlock()
        atomic.AddUint64(&ac.hits, 1)
        return entry, nil
    }

    atomic.AddUint64(&ac.misses, 1)
    return nil, nil
}
Enter fullscreen mode Exit fullscreen mode

The cache operates on two levels—memory and persistent storage. Recent artifacts stay in memory for instant access. Older ones load from disk when needed. We also track hits and misses to measure efficiency. In practice, cache hit rates often exceed 90% once the system warms up.

Cache keys include more than just source code. They incorporate build configuration so different flag combinations don't conflict.

func (bs *BuildSystem) generateCacheKey(pkg string, info *PackageInfo) string {
    h := sha256.New()

    // Include package content hash
    h.Write([]byte(info.ContentHash))

    // Include build configuration
    fmt.Fprintf(h, "GOOS:%s\n", runtime.GOOS)
    fmt.Fprintf(h, "GOARCH:%s\n", runtime.GOARCH)
    fmt.Fprintf(h, "Race:%v\n", bs.config.RaceDetector)
    fmt.Fprintf(h, "Tags:%v\n", bs.config.BuildTags)

    // Include Go version
    fmt.Fprintf(h, "GoVersion:%s\n", runtime.Version())

    return hex.EncodeToString(h.Sum(nil))
}
Enter fullscreen mode Exit fullscreen mode

This attention to detail matters. You might build with race detection enabled for testing but disabled for production. These produce different machine code, so they need separate cache entries. The same applies to different Go versions, operating systems, or architecture targets.

Parallel compilation is where we regain lost time. Independent packages can build simultaneously. The challenge is identifying which packages are independent and managing the concurrent processes efficiently.

type BuildPool struct {
    workers    int
    semaphore  chan struct{}
    workQueue  chan *BuildTask
    results    chan *BuildResult
}

func (bs *BuildSystem) executeParallelBuilds(ctx context.Context, packages []string) error {
    var wg sync.WaitGroup
    var errors []error
    var mu sync.Mutex

    // Start workers
    for i := 0; i < bs.buildPool.workers; i++ {
        wg.Add(1)
        go bs.buildWorker(ctx, &wg, &errors, &mu)
    }

    // Dispatch work
    go func() {
        for _, pkg := range packages {
            info := bs.depGraph.packages[pkg]
            task := &BuildTask{
                Package:     pkg,
                ImportPath:  info.ImportPath,
                Dir:         info.Dir,
                CacheKey:    bs.generateCacheKey(pkg, info),
                Dependencies: info.Imports,
            }
            bs.buildPool.workQueue <- task
        }
        close(bs.buildPool.workQueue)
    }()

    // Collect results
    go func() {
        wg.Wait()
        close(bs.buildPool.results)
    }()

    // Process results
    for result := range bs.buildPool.results {
        if result.Error != nil {
            mu.Lock()
            errors = append(errors, result.Error)
            mu.Unlock()
            continue
        }

        // Store in cache
        entry := &CacheEntry{
            Key:         result.CacheKey,
            Artifact:    result.Artifact,
            Dependencies: result.Dependencies,
            Timestamp:   time.Now(),
        }
        bs.cache.Put(entry)

        atomic.AddUint64(&bs.stats.PackagesBuilt, 1)
    }

    if len(errors) > 0 {
        return fmt.Errorf("build failed with %d errors", len(errors))
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The build pool uses a worker pattern with a semaphore to limit concurrency. This prevents overwhelming the system with too many simultaneous compilations. Each worker picks tasks from a shared queue, compiles the package, and sends results back.

Workers handle the actual compilation using standard Go tools. We're not reimplementing the compiler—we're orchestrating it more efficiently.

func (bs *BuildSystem) compilePackage(task *BuildTask) *BuildResult {
    start := time.Now()

    // Create build command
    args := []string{"build", "-o", task.OutputPath()}
    if bs.config.RaceDetector {
        args = append(args, "-race")
    }
    if len(bs.config.BuildTags) > 0 {
        args = append(args, "-tags", strings.Join(bs.config.BuildTags, ","))
    }
    args = append(args, task.ImportPath)

    cmd := exec.Command("go", args...)
    cmd.Dir = task.Dir

    // Capture output
    output, err := cmd.CombinedOutput()
    if err != nil {
        return &BuildResult{
            Package:  task.Package,
            CacheKey: task.CacheKey,
            Error:    fmt.Errorf("build failed: %v\n%s", err, output),
        }
    }

    // Read artifact
    artifact, err := os.ReadFile(task.OutputPath())
    if err != nil {
        return &BuildResult{
            Package:  task.Package,
            CacheKey: task.CacheKey,
            Error:    err,
        }
    }

    duration := time.Since(start)
    atomic.AddUint64(&bs.stats.BuildTimeNs, uint64(duration.Nanoseconds()))

    return &BuildResult{
        Package:      task.Package,
        CacheKey:     task.CacheKey,
        Artifact:     artifact,
        Dependencies: task.Dependencies,
        Duration:     duration,
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how we capture both output and errors. When a build fails, we need to know why. The error includes the command output, which usually contains the compiler messages explaining what went wrong.

The statistics we collect aren't just for show. They help us understand performance and identify bottlenecks.

type BuildStats struct {
    PackagesBuilt   uint64
    PackagesSkipped uint64
    CacheHits       uint64
    CacheMisses     uint64
    BuildTimeNs     uint64
}

func (bs *BuildSystem) GetStats() BuildStats {
    return BuildStats{
        PackagesBuilt:   atomic.LoadUint64(&bs.stats.PackagesBuilt),
        PackagesSkipped: atomic.LoadUint64(&bs.stats.PackagesSkipped),
        CacheHits:       atomic.LoadUint64(&bs.cache.hits),
        CacheMisses:     atomic.LoadUint64(&bs.cache.misses),
        BuildTimeNs:     atomic.LoadUint64(&bs.stats.BuildTimeNs),
    }
}
Enter fullscreen mode Exit fullscreen mode

These metrics tell a story. A high cache hit rate means we're reusing artifacts effectively. The build time per package helps identify particularly slow packages. Packages skipped indicates how much work we avoided through incremental building.

Now let's put it all together in a main function that shows how to use this system.

func main() {
    // Initialize build system
    bs := NewBuildSystem("/tmp/go-build-cache", 8)

    // Parse command line arguments
    packages := os.Args[1:]
    if len(packages) == 0 {
        packages = []string{"./..."}
    }

    // Execute build
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
    defer cancel()

    if err := bs.Build(ctx, packages); err != nil {
        log.Fatal(err)
    }

    // Show final statistics
    stats := bs.GetStats()
    fmt.Printf("\nBuild Statistics:\n")
    fmt.Printf("Packages built: %d\n", stats.PackagesBuilt)
    fmt.Printf("Cache efficiency: %.1f%%\n", 
        float64(stats.CacheHits)/float64(stats.CacheHits+stats.CacheMisses)*100)
    fmt.Printf("Total build time: %.2fs\n", 
        float64(stats.BuildTimeNs)/1e9)
}
Enter fullscreen mode Exit fullscreen mode

The timeout context is important. Builds shouldn't hang indefinitely. Five minutes is reasonable for most projects, but you might adjust this based on your specific needs.

The storage backend deserves special attention. The file-based implementation works well for individual developers, but teams need shared storage.

type ArtifactStorage interface {
    Load(key string) (*CacheEntry, error)
    Save(entry *CacheEntry) error
}

type FileStorage struct {
    baseDir string
}

func (fs *FileStorage) Load(key string) (*CacheEntry, error) {
    path := fs.keyToPath(key)
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    var entry CacheEntry
    if err := json.Unmarshal(data, &entry); err != nil {
        return nil, err
    }

    return &entry, nil
}

func (fs *FileStorage) Save(entry *CacheEntry) error {
    data, err := json.Marshal(entry)
    if err != nil {
        return err
    }

    path := fs.keyToPath(entry.Key)
    dir := filepath.Dir(path)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return err
    }

    return os.WriteFile(path, data, 0644)
}

func (fs *FileStorage) keyToPath(key string) string {
    return filepath.Join(fs.baseDir, key[:2], key[2:4], key)
}
Enter fullscreen mode Exit fullscreen mode

The directory structure spreads files across multiple subdirectories. This prevents having thousands of files in a single directory, which can slow down filesystems. The first two characters of the hash create the first level, the next two create the second level.

For team environments, you'd implement additional storage backends. A Redis implementation would store artifacts in memory with persistence. An S3 implementation would work for distributed teams. The interface makes swapping backends straightforward.

Error handling in a distributed build system requires careful consideration. When one package fails to build, what happens to packages that depend on it? Our system continues building independent packages but marks dependent ones as failed. This gives you the maximum amount of information about what's broken.

The system also handles cancellation gracefully. If you interrupt the build with Ctrl+C, workers stop cleanly. This prevents leaving the system in an inconsistent state.

Configuration management is another area where custom systems shine. You can have different build configurations for development, testing, and production. Each configuration gets its own cache space.

type BuildConfig struct {
    CacheEnabled   bool
    ParallelBuilds int
    SkipTests      bool
    BuildTags      []string
    RaceDetector   bool
}
Enter fullscreen mode Exit fullscreen mode

You might disable caching during development when you're experimenting rapidly. You might increase parallel builds on powerful CI machines compared to developer laptops. The flexibility adapts to different environments.

Integration with existing workflows matters. This system doesn't replace your entire toolchain. It complements it. You can use it in CI/CD pipelines to speed up builds. Developers can use it locally. The cache can be shared between environments, so CI doesn't rebuild what developers have already built.

Testing such a system requires a different approach. You need to verify that incremental builds produce identical results to clean builds. You need to ensure cache correctness—that changing a file invalidates the right artifacts. You need to test concurrent behavior under load.

Performance characteristics in production differ from benchmarks. Memory usage scales with the number of concurrent compilations. Disk usage grows with cache size but can be managed with expiration policies. Network latency affects distributed cache performance.

The human impact of faster builds is substantial. Developers stay in flow state instead of waiting. CI pipelines complete faster, providing quicker feedback. Team velocity increases because less time is spent waiting for compilation.

There are tradeoffs, of course. This system adds complexity to your toolchain. It requires maintenance. The cache needs monitoring to prevent disk space issues. But for large projects, these tradeoffs are worth it.

The implementation I've shown is a starting point. You might add features like remote execution, where compilation happens on dedicated build servers. You might add predictive prefetching, where the system anticipates what you'll build next. You might integrate with cloud storage for global teams.

What surprises many people is how much improvement comes from simple optimizations. Better dependency analysis alone can cut build times significantly. Adding parallelism multiplies the gains. Caching provides the final boost.

The journey from slow builds to fast builds transforms team dynamics. Instead of "I'll fix it after this compile," you get immediate feedback. Instead of context switches while waiting, you maintain focus. The cumulative effect over months is substantial.

Building such a system teaches you about your codebase in unexpected ways. You discover circular dependencies you didn't know existed. You find packages with excessive imports. You identify compilation bottlenecks.

The technical lessons extend beyond build systems. The patterns apply to any system requiring dependency management, caching, and parallel execution. The discipline of measuring performance leads to better software design.

In practice, adoption happens gradually. Start with the standard tools until they become painful. Then implement the simplest version of this system. Add features as needed. Measure improvements at each step.

The code I've shared is complete enough to experiment with. Start with a moderate-sized project. Compare build times before and after. Adjust the worker count based on your machine's capabilities. Monitor cache efficiency.

Remember that the goal isn't perfection. It's practical improvement. If you cut build times from five minutes to one minute, that's an 80% reduction. Your team will notice immediately. The productivity gains compound over time.

Large-scale Go projects don't have to suffer slow builds. With careful design and the right architecture, you can maintain rapid iteration regardless of codebase size. The tools exist. The patterns are proven. The results speak for themselves.

Building software should be about creating, not waiting. When your tools get out of the way, you can focus on what matters—solving problems and delivering value. That's what high-performance build systems enable.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)