Projects do not become risky because they have a lot of parts. A checkout flow can include payment gateways, fraud checks, inventory reservations, emails, analytics, feature flags, and a deployment pipeline, and still be something a team can reason about. Hard, yes. Automatically complex, no.
The distinction that matters here is complicated versus complex.
A complicated system may be large, specialized, and tedious to understand, but most of its relationships are fixed and visible. With enough time and the right expertise, engineers can break it apart, trace the contracts, find the owners, and make a reasonable prediction about what will happen next. It may take patience. It may take three monitors and a notebook. Still, the work has edges.

A complex system behaves differently. Its behavior comes from the interactions, not just the parts. Feedback loops, adaptation, nonlinear effects, self-organization, and sensitivity to timing or initial state can produce outcomes no single component explains on its own. A small change in code, config, load, data freshness, or user behavior can matter more than anyone expected. Often the cause looks obvious only after the system has already reacted.
You see the difference most clearly after a change. In a complicated system, the team can usually say, “This is the relevant code, this is the contract, these are the tests, this is the blast radius.” In a complex system, the same change may alter timing, cached data, rollout cohorts, queue behavior, user workflows, or incentives. The system after the change is not quite the system the team inspected before it.

This is not a plea for naive simplicity. Serious web systems need layers: caches, retries, migrations, feature flags, background jobs, observability, review processes, and now AI-assisted workflows. Those are healthy complications when the connections are explicit, owned, observable, and bounded. They become sources of complex behavior when the connections are implicit.
What an implicit connection is
An implicit connection is a dependency, assumption, timing relationship, data relationship, ownership relationship, or feedback loop that affects behavior but is not visible where people make changes.
Some live in code. A component relies on a context provider several levels above it. A custom hook depends on cleanup timing. A shared helper quietly carries billing policy. A worker reads a table that belongs to another domain because the API path was too slow.
Others live at runtime. A retry policy assumes idempotency. A queue assumes workers can keep up. A cache assumes invalidation happens quickly enough. A deploy assumes old and new versions can safely overlap. Organizations have their own version too: a runbook depends on one engineer’s memory, an alert has no clear owner, or a pull request needs a reviewer nobody named.
Dependencies are not the enemy. Systems need them. The trouble starts when the dependency is undocumented, untested, unowned, or visible only after production changes behavior. Local reasoning stops being trustworthy. An engineer changes one thing, while the actual result is decided by relationships the local code did not show.
Here are eight common ways that happens.
Failure path 1: Structural coupling makes local edits nonlocal
Structural coupling appears when code boundaries and behavior boundaries stop matching.
A frontend imports a domain helper, which imports a server type, which assumes a database enum. A shared package becomes the home for product policy, pricing rules, analytics names, and permission checks. A background worker reads another team’s table because the proper API was slow. Two services publish and consume each other’s events until neither can change a schema without pulling the other into the room.
Each shortcut can look reasonable when it is made. The cost shows up later. A helper change meant for checkout changes account onboarding. A database enum rename breaks a reporting job. A UI copy tweak depends on a backend constant that also controls experiment assignment. The repository still has modules, but the real change boundary now runs through files, teams, and runtime consumers.
Ordinary impact analysis turns into guesswork here. “Who consumes this?” should have an answer. It should not require a hunt through imports, dashboards, Slack memory, and last quarter’s incident notes. Reviews become defensive because reviewers are trying to infer unstated consumers instead of checking a named contract.
Start with the dull guardrails. Enforce package and service boundaries. Put API, event, and data contracts where consumers see them. Track owners and downstream consumers. Keep shared libraries small enough that review still means something. Contract tests belong at the seams where independent teams or deploy units meet. Boring controls are what keep local edits local.
Failure path 2: Temporal coupling creates race conditions
Temporal coupling appears when correctness depends on timing the interface does not express.
A request handler assumes a background worker has already processed an event. A user double-clicks submit and creates two payments before idempotency is checked. New code starts reading a queue payload before every worker understands it. The API request is canceled, but the job it scheduled keeps running. A retry arrives after a newer state update and overwrites it.
Race conditions make the problem plain because the code can be “right” line by line and still be wrong as a system. Two browser tabs update the same profile from stale copies. Two workers reserve the last inventory item because the read and write are not protected by a transaction or compare-and-set condition. A webhook handler receives subscription canceled, subscription renewed, and invoice paid in an order the business logic did not expect.
Web systems create these timing relationships all day: overlapping API requests, optimistic UI updates racing with server reconciliation, delayed queue retries, workers scaling up and down, rolling deploys, users abandoning flows halfway through. If the timing assumption is not represented in idempotency keys, locks, transactions, version checks, state machines, or cancellation handling, people are running on folklore.
Local tests usually run the steps in a clean sequence. Production runs them concurrently, partially, repeatedly, late, or after a timeout. That is the shift from complicated to complex. The problem is not the number of workflow steps. The problem is that correctness depends on timing relationships nobody named.
Model the asynchrony instead of assuming it away. External triggers need idempotency keys. Critical writes need transactions or conditional updates. State transitions should be explicit, and cancellation should be treated as a real path. Test duplicates and out-of-order events. Design deployments so old and new code can overlap instead of pretending rollout is instantaneous.
Failure path 3: Reactive render graphs hide feedback loops
Frontend code looks local because we write it in components. Runtime behavior is less tidy.
React render cycles, hooks, context propagation, memoization, parent-child updates, custom hooks, and cleanup timing form a reactive graph. A state update in one component can change props, invalidate memoized values, trigger effects, update context, and cause another component to render under assumptions the original author never saw.
The issue is not that React is bad or mysterious. The issue is treating render behavior like ordinary function calls. A useEffect fetches data whenever a dependency changes, but the dependency array omits a filter value and leaves a stale closure. A parent creates a fresh object on every render, invalidating a child’s useMemo and causing a query hook to run again. A context provider updates on every keystroke, so unrelated consumers render and fire effects. A custom hook opens a subscription, but its cleanup happens later than the author expected during route changes, and old listeners process new events.
That is an implicit connection. The code that changes one state value may not show the graph it perturbs. A local refactor changes when a callback is recreated. That changes an effect dependency. Fetch timing changes. Cache contents change. Another component now sees different data during a transition. The symptoms are familiar: duplicate requests, flicker, stale data, lost input, state updates after unmount.
You can feel this failure path when developers stop asking, “What does this component do?” and start asking, “What does the whole tree do during this sequence of renders?” The component model is still useful at that point. It is just not enough by itself.
Keep effects focused on synchronization with external systems, not derived calculations. Treat dependency arrays as contracts. If a value can be derived during render, do not store it. Values passed through context need stability. Custom hooks should stay small enough that subscription and cleanup behavior are reviewable. Test the render interactions users actually hit: loading, cancellation, rapid input, navigation, parent-child updates, and stale server responses.
Failure path 4: Copied and derived state drifts out of coherence
State becomes an implicit connection when the same business fact exists in several places and nobody has written down which one is authoritative.
A product’s availability may live in the database, an API cache, a CDN object, a frontend query cache, local component state, a search index, a read model, a background job payload, and an analytics pipeline. Duplication is sometimes necessary. Losing the coherence rules is the problem.
The database says a plan is canceled. The billing UI still shows it as active. The API cache serves old entitlements. The CDN serves a stale page. A worker sends an upgrade email. Analytics records a conversion that never completed. No single component has obviously exploded. The system is answering the same business question from different points in time.
Frontend code has the same shape at a smaller scale. A form copies server data into local state. A component derives display status from props. A query cache updates optimistically. A background refetch returns older data. If the code does not define which source wins, when invalidation happens, and how conflicts are resolved, the user sees state jump backward or alternate between versions.
Caches, read models, and async jobs are practical tools. They also create obligations. Each copy needs a source of truth, a freshness expectation, and a propagation rule. Without those, bug investigations become a tour of stale snapshots: which copy did this user observe, when was it refreshed, and who was allowed to update it?
Name the authority for each business fact. Prefer derived values over stored copies where you can. Cache keys and invalidation policies should match business identity, not incidental request shapes. Version events and read models. Make optimistic updates reversible. Where eventual consistency is intentional, say so in the interface, tests, and runbooks instead of leaving users and on-call engineers to infer it.
Failure path 5: Flags and config multiply behavior paths
Feature flags, experiments, permissions, rollout cohorts, kill switches, remote config, and environment settings are useful because they separate deployment from release. They also multiply production behavior.
A flag begins as a safe rollout switch. Then it gains experiment variants, enterprise overrides, support-team exceptions, regional behavior, and a stale default in one environment. A permission check appears in frontend routing, API authorization, and background job filtering. A kill switch disables the visible feature but not the worker still processing its queue. A remote config value changes at runtime, while one service reads it only at startup.
The local code still looks harmless: if enabled, do the new thing. Real production behavior is a matrix of cohort, account permission, experiment assignment, environment, app version, cached config, and deployment stage. A bug report from one customer cannot be reproduced by an engineer whose flags, permissions, and cached settings are different.
Control also spreads across the organization. Product, support, growth, operations, automated rollout systems, and sometimes learned ranking, fraud, or personalization components can all influence behavior without a deploy. That is not automatically wrong. It gets risky when ownership, input signals, rollback rules, and audit trails are weaker than the behavior they control.
This path gets complex because the system no longer has one production behavior. It has many, and some are accidental. The number of paths grows faster than test coverage or reviewer attention. Stale config and unexpired flags keep old behavior alive long after everyone thinks the rollout is finished.
Treat flags and config as production code, not temporary notes to self. Every switch needs an owner, purpose, creation date, expected removal date, and safe default. Test important combinations, not just the happy path. Centralize permission checks enough to audit them. Kill switches should stop all relevant work, including workers and scheduled jobs. Remove flags after rollout. Yes, actually remove them.
Failure path 6: Contract and version drift breaks compatibility windows
Contracts let teams change independently: API shapes, database schemas, event payloads, generated clients, ORM models, reporting schemas, webhooks, and importable package interfaces. Version drift starts when producers and consumers do not move together and the compatibility window is left to luck.
A database migration adds a non-null column before old writers know how to populate it. An API removes a field because the current frontend no longer uses it, but an older mobile client and a partner integration still do. An event publisher changes status values while a reporting job and an old consumer treat unknown values as failure. One service regenerates an ORM model; another still builds queries with the previous relationship name.
The local change may be correct for the current application and still wrong for the deployed system. Real deployments include old clients, old workers, cached generated code, lagging read models, third-party consumers, backfills, replays, and reporting pipelines. They do not all update at the same time just because the main app did.
This is how change order becomes an implicit contract. The team thinks the contract is “this field exists.” Production reveals the harder contract: “this field behaves safely for every consumer still sending, receiving, storing, replaying, reporting, or transforming this data during the migration period.”
Be explicit about compatibility. Expand-migrate-contract database changes exist for a reason. Version externally consumed APIs and events. Keep generated clients in sync through automated checks, and validate old and new payloads during the transition. Migration plans need to include reporting, backfills, replays, and old consumers. A migration is not done when the happy-path app works. It is done when the compatibility window has closed cleanly.
Failure path 7: Recovery mechanisms amplify the incident
Retries, queues, autoscaling, circuit breakers, fallbacks, rate limits, health checks, and backpressure are supposed to make systems more resilient. They cause trouble when their interactions are implicit.
Retry amplification is the usual example. A service times out calling a dependency, retries three times, and every caller does the same. Queues grow because workers keep retrying work that cannot succeed. Autoscaling adds instances, which increases database connections. Health checks fail under load and restart processes that were slowly recovering. A fallback returns partial data that triggers user refreshes and duplicate submissions. A circuit breaker opens correctly, but the caller responds by enqueueing more work.
The recovery mechanism has become part of the incident. Failure creates activity; activity worsens the failure.
Backpressure is often the missing agreement. If producers can create work faster than consumers can process it, the system needs a way to slow producers, shed load, prioritize work, or reject requests clearly. Without that, queues are just delay with a nicer dashboard. The incident appears to move around the platform because every component is locally trying to be helpful while the whole system is getting busier.
Recovery paths are also under-tested. Happy-path tests prove that retries eventually succeed. They do not prove that retries stop, jobs are idempotent, rate limits protect shared dependencies, or the system recovers when the dependency stays down.
Give retries a budget. Add jitter and exponential backoff. Make jobs idempotent before retrying them. Track queue age as well as depth. Producers need a defined point where they slow down or stop. Rate limits and circuit breakers need owners and alerts, not just configuration. Test sustained failure, not only transient failure, because production is allowed to be rude.
Failure path 8: Observability gaps hide the real loop
Observability gaps become implicit connections when teams can see components but not causality.
The service dashboard is green. The database dashboard is green. The queue dashboard is green. The deployment tool says the rollout succeeded. Meanwhile users are refreshing, retries are climbing, jobs are aging, cache hit rates are falling, and support tickets are increasing.
Component health is necessary. It is not enough. Many serious failures live between components: request A created job B, job B updated read model C, cache C served frontend D, frontend D retried request E, and request E created more jobs. Without traces, correlation IDs, causal dashboards, and ownership mapping, the team sees symptoms scattered across tools instead of one loop.
The cost is delay. Engineers inspect whichever system paged first. Teams argue over ownership. Alerts name symptoms but not accountable responders. Incident notes explain what was restarted, not why the loop formed.
This turns complicated operations into production uncertainty because the team cannot learn at the speed of the system. If behavior changes faster than humans can connect cause and effect, each mitigation is partly speculative. A hotfix may help, do nothing, or feed the loop.
Make observability causal, not decorative. Trace critical user journeys across services and jobs. Propagate correlation IDs through requests, queues, and logs. Alert on saturation, retry rate, queue age, cache churn, and tail latency, not only error count. Alerts need owners and runbooks. Incident reviews should look for repeated feedback loops and missing instrumentation. Dashboards should help people explain behavior, not admire it.
Agentic AI amplifies the current system state
Agentic AI is not a ninth failure path. It is an amplifier for the change process the team already has.
An agent can read repository context, propose edits, run tools, respond to test output, and widen the amount of code a person can attempt to change. That can be useful. It does not create clarity by itself. It increases the rate and breadth of change inside the system as it currently exists.
In a project with explicit boundaries, contracts, tests, ownership, and observability, that amplification can help. An engineer using an AI agent can keep the work inside a bounded component, verify relevant behavior, and produce a reviewable diff because the system tells both the human and the agent where the edges are. Clear contracts make work easier to split without guessing what must remain true.
In a project full of implicit coupling, stale context, weak tests, or unclear ownership, the same amplification makes ambiguity arrive faster. An agent asked to “fix checkout” may touch validation, API handlers, cache invalidation, tests, and migration code because no smaller boundary is trustworthy. Multiple contributors can make locally sensible edits while unknowingly changing the same runtime behavior. Reviewers then face broader diffs and weaker confidence because the missing context was never explicit.
So the interesting question is not whether engineers should use agentic AI. They will. The question is what the existing system state will amplify. If task scope, allowed files, contract references, test commands, owners, and escalation rules are clear, agents can help engineers keep a complicated system maintainable. If those signals are absent, agents help the team change the system faster than the organization can understand the result.
Warning sign: every change requires system-wide impact analysis
When every change requires system-wide impact analysis, that is a symptom. It is not the root failure path.
The root problem is usually some combination of the eight paths above: structural coupling, temporal coupling, reactive render interactions, incoherent state copies, flag and config sprawl, contract drift, recovery amplification, and missing causal observability. The team experiences it as analysis burden because the system has stopped presenting reliable local boundaries.
This is easy to misread as diligence. Careful engineers should want to understand impact before touching production systems. But if routine work is safe only after studying the whole system, the system is telling you its connections are not explicit enough. It may still run. It may even look stable. Its change behavior has become complex.
The practical response is to fix the connections, not to normalize permanent whole-system review. Make ownership discoverable. Move contracts closer to boundaries. Add focused tests around seams. Remove stale flags. Document compatibility windows. Instrument causal paths. Split change scopes so contributors can reason about a bounded area again. The goal is not less responsibility. It is responsibility people can actually carry locally.
Conclusion: keep the connections explicit
Complicated web systems are not failures. A serious system may need services, migrations, queues, caches, feature flags, observability, and AI-assisted workflows. The question is whether those parts remain analyzable.
Implicit connections push a project across the line. They make local edits nonlocal, timing assumptions fragile, render behavior hard to predict, state incoherent, rollouts difficult to reproduce, contracts unsafe to evolve, recovery mechanisms self-amplifying, and incidents hard to explain. Once enough of those connections accumulate, the project can keep running while becoming resistant to deliberate change.
The answer is not to avoid sophistication. It is to invest in the discipline that keeps sophistication from turning into surprise: explicit boundaries, named contracts, owned flags, compatibility windows, modeled concurrency, coherent state rules, bounded retries, backpressure, causal observability, and reviewable task scope for both humans and agents.
That is how a web project stays complicated in the useful sense: hard enough to solve real problems, but structured enough that teams can understand what they are changing before production has to teach them.