Skip to content

Design decisions

A running log of decisions made during design, with the reasoning. When tempted to change one of these, read the original reasoning first — most are intentional trade-offs, not oversights.


ADR-001: Three-package monorepo (core / browser / server)

Section titled “ADR-001: Three-package monorepo (core / browser / server)”

Decision. Ship as @trackbridge/core, @trackbridge/browser, @trackbridge/server rather than a single package.

Reasoning. Browser and server have different runtime targets (DOM vs Node), different bundling needs (platform: 'browser' vs platform: 'node'), and different dependencies (gtag types vs Google Ads API client). Bundling them together would force users to ship Node-only code to the browser or browser-only code to Node. Splitting via subpath exports (@trackbridge/sdk/browser and @trackbridge/sdk/server) was considered but rejected — it complicates the build and confuses bundlers in some configurations. Three packages is cleaner.

Trade-off. Users install two packages instead of one. Worth it.


ADR-002: Linked versioning across all three packages

Section titled “ADR-002: Linked versioning across all three packages”

Decision. All three packages release at the same version, always (Changesets linked config).

Reasoning. Browser and server depend on @trackbridge/core for normalization parity. If a user installs @trackbridge/browser@0.3.1 and @trackbridge/server@0.2.7, they could end up with subtly different normalization behavior — silent dual-send failure. Linked versioning makes it impossible to mix incompatible versions.

Trade-off. Patch bumps to one package bump all three. Slightly noisier release log. Not worth the correctness risk.


Decision. Use tsup (esbuild-based) for all package builds.

Reasoning. Fast, minimal config, ships dual ESM+CJS + types out of the box. tshy was the runner-up but has more opinionated defaults that fight against monorepo patterns. Raw tsc requires more config for dual-format output.

Revisit if. Build times become an issue (unlikely at this scale), or bundle-size optimizations beyond tree-shaking become necessary.


Decision. Strict TypeScript + Prettier only. No ESLint configuration.

Reasoning. Strict TS catches the bugs that matter (noUncheckedIndexedAccess, noImplicitOverride, etc.). ESLint configs are a maintenance tax that grows over time. The no-floating-promises rule is the one genuinely useful lint rule — TS strict doesn’t catch it — but the cost of maintaining a full ESLint config to get one rule isn’t worth it at this stage.

Revisit if. Bugs start landing that a lint rule would have caught and TS doesn’t.


ADR-005: transactionId auto-generated when missing, dual-send disabled

Section titled “ADR-005: transactionId auto-generated when missing, dual-send disabled”

Decision. When trackConversion() is called without transactionId, generate a UUID v4 and use it for the call. Disable dual-send for that call and emit a warning.

Reasoning. The alternative — making transactionId strictly required — is purer but hurts the “first 5 minutes” experience for simple cases (lead form, newsletter signup) where the developer doesn’t have a natural transaction ID. Auto-generation lets these cases work. But auto-generation on both sides would generate different IDs, causing silent double-counting — strictly worse than not dual-sending at all. So the rule is: auto-generated → no dual-send, with a loud warning.

Trade-off. Users who don’t read the warning may be surprised that single-call usage doesn’t dual-send. Acceptable because the warning is loud and the docs are clear.


Section titled “ADR-006: Click identifier auto-capture with built-in cookie helper”

Decision. Browser SDK auto-captures gclid / gbraid / wbraid from URL params and writes them to first-party cookies (_tb_gclid, etc.) by default. Configurable via clickIdentifierStorage: 'cookie' | 'memory' | 'none'.

Reasoning. Manual click ID capture is the second thing devs get wrong (after enhanced conversions hashing). Making it automatic by default removes a class of bugs and is a clear DX win over alternatives.

Why first-party cookies and not Google’s _gcl_aw. Avoids conflicts with gtag if both are running, makes debugging clearer (we know who set the cookie), doesn’t overwrite existing state.

Why SameSite=Lax. Needed so cookies survive the redirect from the ad click. Strict would break the whole point.

Trade-off. One more set of cookies on the user’s site. Documented in Cookies.


Decision. When consentMode: 'v2' and ad_storage consent is unknown or denied, click identifier values stay in memory only. Cookies are only written if/when consent is granted.

Reasoning. Writing cookies before consent is a GDPR violation. Memory-only is the correct fallback. The state machine is documented in Deduplication & transactionId.


ADR-008: Two methods (trackConversion, trackEvent), not one unified track()

Section titled “ADR-008: Two methods (trackConversion, trackEvent), not one unified track()”

Decision. Conversions and GA4 events have separate methods.

Reasoning. They have meaningfully different shapes — conversions need transactionId and userData for enhanced conversions, events need clientId on the server for session continuity, and they hit different APIs (Google Ads vs GA4 Measurement Protocol). Forcing them through one method either makes the unified type miserable (lots of conditional fields) or hides the differences in ways that bite users later. Two methods, autocomplete is sharper, mental model matches Google’s own.


ADR-009: GA4 server-side support in v1, not v1.1

Section titled “ADR-009: GA4 server-side support in v1, not v1.1”

Decision. Ship full GA4 Measurement Protocol support in v1.

Reasoning. Skipping it would make v1 feel half-built. MP is well-documented, and the clientId continuity problem is one Trackbridge can solve nicely (forcing it as a required field on the server). Worth the additional implementation effort to ship a complete v1.


ADR-010: _gl cross-domain linker NOT in v1

Section titled “ADR-010: _gl cross-domain linker NOT in v1”

Decision. Single-domain only for v1. Cross-domain _gl linker is v1.1+.

Reasoning. Real complexity (URL rewriting on outbound links, parsing on inbound), needed by a minority of users (mostly enterprise multi-domain setups). Document the limitation and ship without it. Add later based on user demand.


ADR-011: 24-hour dedup window not solved at SDK level

Section titled “ADR-011: 24-hour dedup window not solved at SDK level”

Decision. SDK does not attempt to handle the case where server-side conversions fire >24h after client-side ones (outside Google’s dedup window).

Reasoning. The SDK has no way to know whether a given server call is “in time” — only Google’s backend does. Trying to implement this client-side would require persisting all client conversions and inspecting them at server-call time, which is invasive and still wouldn’t be authoritative. The Trackbridge Dashboard (post-v1) is the right place to surface this — it has both sides’ timestamps and can flag suspicious patterns.