Skip to content

PATTERN Cited by 1 source

Decouple frontend build from backend artifacts

Context

In a monorepo with both a backend (Python, Ruby, Java, Go) and a frontend (TypeScript, JavaScript), it's common for the frontend build's dependency graph to transitively include the backend's built artifacts — typically because:

  • Protobuf / API-schema artifacts generated by the backend build are consumed by the frontend.
  • The frontend's build script is written in the backend's language and imports backend modules.
  • Shared utilities live in a package that the backend "builds first" as a precondition.

When this happens, every backend change invalidates every frontend cache entry, even if the frontend's actual source didn't change. At Slack's Quip/Canvas, this pattern cost ~35 minutes per build — more than half of the original 60-minute total.

Problem

The backend and frontend logically have independent source trees that evolve at different rates, but the build graph has edges that make them share a cache key. Cache hit rate on the frontend goes to near-zero because backend sources churn continuously.

Concretely (from the Slack post):

That edge was costing us an average of 35 minutes per build — more than half the total cost! — because every change was causing a full backend and frontend rebuild, and the frontend rebuild was especially expensive.

Solution

Sever the dependency edge between backend artifacts and frontend build actions. Execute this in three phases:

  1. Audit the edge. Identify why the frontend's cache key includes backend sources. Typical causes: build orchestration written in the backend's language, shared test fixtures, transitive srcs globs that capture backend files.
  2. Relocate build orchestration. Rewrite it in a constrained build DSL (Starlark for Bazel, Bazel-compatible Buck rules, or the equivalent for your build system). This enforces isolation from application code by the language itself.
  3. Pin cross-language dependencies to declared artifacts. If the frontend genuinely needs a backend-generated file (e.g. a generated .d.ts from a Protobuf schema), declare that specific file as the input — not the upstream backend sources that produced it. The generated file's hash is stable across backend implementation churn, but changes when the schema actually changes.

Slack's execution

From the post:

Over several months, we painstakingly unraveled the actual requirements of each of our build steps. We rewrote Python build orchestration code in Starlark, the language Bazel uses for build definitions. Starlark is a deliberately constrained language whose limitations aim at ensuring builds meet all the requirements for Bazel to be effective. Building in Starlark helped us enforce a full separation from application code. Where we needed to retain Python scripts, we rewrote them to remove all dependencies save the Python standard library: no links to our backend code, and no additional build dependencies.

Post-refactor build graph (schematic):

Before:
  Python sources ──→ Python backend ──→ TS build ──→ frontend bundles
  (every Python change invalidated every bundle)

After:
  Python sources ──→ Python backend
  TS sources ──→ TS build ──→ frontend bundles
  (decoupled; Python changes don't touch frontend cache)

Outcomes

  • ~35 minutes / build reclaimed as soon as the coupling was cut.
  • Cache hit rate on frontend bundles went from zero to high — any single-language change hits the cache for the other language's artifacts.
  • Blast radius for engineers shrank: a Python refactor can no longer break the TypeScript build or alter the frontend output.

Prerequisites

  • A build system capable of enforcing input declarations (Bazel, Buck, Pants, Nix) — the cut is nearly impossible to enforce in Make-style imperative builds.
  • A constrained build DSL (Starlark, BUCK rules) to host the relocated build orchestration.
  • Willingness to invest significant refactoring effort; Slack described this as several months' work.

Variations

  • Shared Protobuf as the seam: if the only cross-language dependency is the API schema, declare the generated .d.ts / .py files as the artifact boundary. Both backend and frontend builds depend on the generated files; neither depends on the other's sources.
  • Pre-built backend runtime: ship the backend as a pre-built artifact that the frontend's dev environment references. Test pyramid fans out from the artifact interface.

Anti-patterns

  • Sharing a build script between languages. The script will accumulate dependencies on both sides and become the coupling.
  • Globbing srcs across language boundaries. A glob(["**/*"]) in a BUILD file captures both backend and frontend sources, reintroducing the problem.
  • "We'll fix it later". The longer the coupling persists, the more build-time logic accumulates on top of it, making it harder to cut.

Seen in

Last updated · 470 distilled / 1,213 read