How Session Flags Enabled Trunk-Based Development at OLX


The Problem With Long-Lived Branches

If you’ve worked on a team with more than a handful of engineers, you’ve felt this pain.

A developer creates a feature branch. It lives for two weeks — sometimes three. By the time it’s ready to merge, the develop branch has moved on. Conflicts pile up. The MR grows large because multiple things that couldn’t be shipped independently got bundled together. Reviewers struggle to follow the thread. QA has to test everything in one go. And if something breaks, you’re debugging a diff that’s hundreds of lines long.

That was the reality on the OLX India PWA team. Multiple squads, parallel work, a single high-traffic codebase — and a development workflow that was compounding the problem with every sprint. Average merge time was sitting at 4 days. Testing two features in parallel meant either spinning up a separate environment or making someone wait their turn.

To solve this we built a lightweight pattern directly into the SSR pipeline — no new infrastructure, no third-party tooling. We call it session flags, and over six months it brought merge time down to 2 days while more than tripling monthly MR throughput.


The Core Idea

Merge code to the development branch continuously, but keep new behavior invisible behind a flag.

A flag is a string identifier named after the JIRA ticket it belongs to. Append it as a query parameter to any URL on the site, and the server picks it up, stores it in a session cookie, and every layer of the application — SSR, Redux, React components, API controllers — can branch on whether that flag is active.

One URL visit activates the feature for the entire session, across all pages, without touching production for anyone else. Multiple flags run simultaneously:

https://www.olx.in/?features=olxin-1234,olxin-5678

When the feature ships, the flag guard is removed and the new behavior becomes the default. The flag dies with the ticket.


Why This Works for an SSR Application

On a client-rendered SPA, conditional rendering based on a cookie is straightforward. On a server-rendered application it’s harder — the HTML the server generates must match what React produces during hydration. A flag that only activates on the client causes hydration mismatches and flickering.

The key is resolving flags before any data fetching happens. When a request arrives, the server reads the features query param (or falls back to the session cookie) before server-side API calls are made or Redux is populated. Server-side API calls can therefore branch on the flag, and the HTML that reaches the browser already reflects the correct state. Flags then travel with the serialized Redux store to the client, where a HOC and a small helper make them available everywhere.

The result: server and client always agree. No flicker, no mismatch.


How the End-to-End Process Works

The workflow change is small for any individual developer, but the discipline it creates across the team is significant.

1. Build behind a flag, merge to develop continuously

Every piece of new functionality — UI changes, API integrations, tracking, config changes — is wrapped behind a session flag from day one. Instead of one large MR that bundles everything together, each piece becomes its own smaller MR, all merged to the develop branch behind the same flag. Reviewers see focused diffs. The pipeline deploys continuously to staging.

2. QA tests with the flag on

When a feature is ready for testing, QA visits the site with the flag in the URL. The cookie persists it across navigation for the entire session. Multiple features can be tested in parallel by different QA engineers using different flags — no dedicated environments needed.

3. Flag removal — the quality gate

Once QA signs off, the developer raises a flag removal MR — removing the guard, cleaning dead code, and making the new behavior the default. The commit message follows a convention — Remove session flag in OLXIN-1234 — which surfaces the feature in the release log. Without this MR, the feature does not appear in release notes, which turned out to be a strong enough incentive that the team adopted the habit consistently.

4. Release

A release candidate is cut from develop on a regular cadence. Because every feature has already been validated behind its flag before removal, what goes into the release is known, tested code. The pre-release sanity check is quick — no surprises.


The Impact

The transition happened in July 2024. The results were visible within the first quarter — and have held ever since.

PeriodAvg. Merge TimeAvg. MRs / MonthPeak MRs
Apr – Jun 20244 days4358
Jul – Sep 20243 days121178
Oct – Dec 20242 days95.7132
Mar – Jun 20262 days139224

Merge time halved within six months and has stayed there. What’s more telling is the throughput trajectory — from a peak of 58 MRs in the pre-flag era to 224 nearly two years later — even as the team doubled in size, the per-engineer delivery pace kept improving rather than regressing.

The 2-day average isn’t a ceiling the team is fighting to maintain; it’s become the natural pace of working. Reviews are faster because diffs are smaller. QA runs in parallel without environment coordination. Engineers can validate real behavior in production before a feature is visible to anyone else.


Implementation Patterns

These are illustrative patterns — the specifics can vary with your framework and state management setup.


The SSR Controller — where it all starts

The SSR controller resolves flags before any data fetching or Redux population. This ordering is critical.

// SSR controller — runs before fetchData
const sessionFeatures = req.query?.features || req.cookies?.['sf'];

if (sessionFeatures) {
  const featuresList = sessionFeatures.toLowerCase().split(',');
  reqProps.sessionFeatures = featuresList;

  res.cookie('sf', sessionFeatures, {
    httpOnly: false, // must be readable by client JS
    secure: true     // false in local dev
  });
}

// Dispatch into Redux before rendering
store.dispatch(setReqConfig(reqProps));

The query param always wins over the cookie. httpOnly: false is deliberate — client-side code reads the cookie after hydration. Lowercase normalization means flag checks in application code don’t need to worry about casing.


Redux — storing flags in shared state

// reducer
case 'SET_REQUESTS_CONFIG':
  return {
    ...state,
    sessionFeatures: action.config.sessionFeatures // string[] | undefined
  };

The serialized store carries flags to the browser on first load.


The withSessionFeature HOC

A Higher-Order Component reads sessionFeatures from Redux via a memoized selector and injects it as a prop. Components receive an array and check it — they don’t need to know where it came from.

// withSessionFeature HOC definition
const selectSessionFeatures = createSelector(
  state => state.api.sessionFeatures,
  features => features || []
);
const withSessionFeature = Component => {
  const Wrapped = props => <Component {...props} />;
  Wrapped.displayName = `withSessionFeature(${Component.displayName || Component.name})`;

  return connect(
    state => ({ sessionFeatures: selectSessionFeatures(state) })
  )(hoistNonReactStatics(Wrapped, Component));
};

hoistNonReactStatics ensures static methods like fetchData remain accessible after HOC composition — without it, the SSR data fetching layer loses the static method.


HOC usage — branching in components

const MyPage = ({ sessionFeatures, ...rest }) => {
  const isEnabled = (sessionFeatures || []).includes('olxin-1234');
  return isEnabled ? <NewExperience {...rest} /> : <ExistingExperience {...rest} />;
};

export default compose( withSessionFeature, connect(mapStateToProps, mapDispatchToProps))(MyPage);

sessionFeatures can also be threaded into selectors via mapStateToProps:

const mapStateToProps = (state, ownProps) => ({
  breadcrumbs: breadcrumbSelector(state, ownProps, ownProps.sessionFeatures),
});

Pattern: SSR data fetching

Use this when the API call itself should differ. sessionFeatures arrives as part of the request context passed to fetchData.

static fetchData(dispatch, _, { sessionFeatures }) {
  const features = sessionFeatures || [];
  return features.includes('olxin-1234') ? dispatch(fetchNewEndpoint()) : dispatch(fetchExistingEndpoint());
}

If this branching happens only on the client, the server renders one thing and React hydrates another — causing a mismatch.


Pattern: Imperative helper — outside React

For actions, selectors, thunks, and utility modules. Reads the cookie first, falls back to Redux state.

const getSessionFeatures = () => readCookie('sf') || store.getState().api.sessionFeatures || [];

For flags referenced across many files, centralise rather than scatter the ticket ID:

export const isNewFeatureEnabled = () => (getSessionFeatures() || [] ).includes('olxin-1234');

Decision matrix

NeedPattern
Different API call on first page loadSSR fetchData with sessionFeatures from reqProps
Conditional UI renderingwithSessionFeature HOC → sessionFeatures prop
Logic in actions, selectors, thunksgetSessionFeatures() helper
Same flag used across 3+ filesCentralized helper in featureFlag.ts

Session Flags vs. Permanent Feature Flags

Session flags are not a replacement for market-wide feature flags or environment-wide flags. Those are still needed for gradual rollouts and A/B experiments.

Session flags are specifically for ticket-scoped integration work. Temporary by design. And because anyone can append ?features= to a URL, they should never gate security-sensitive behavior, payment flows, or PII access.

DimensionSession flagsConfig feature flags
ScopeOne browser sessionAll users / percentage rollout
ActivationURL param or cookieServer config / remote config
LifetimeTicket lifecycleLong-lived
AudienceQA, developersProduction users
Use caseIntegration testingGradual rollout, A/B

Risks & Guardrails

No pattern is free of trade-offs. These are the failure modes worth planning for.

Flag ownership and expiry. Session flags have no built-in expiry. If the developer who introduced a flag moves on or the ticket closes without cleanup, the guard stays in the codebase indefinitely. The fix is process: flag removal must be a tracked work item, not an afterthought. Treat orphaned flags as tech debt and audit periodically with a codebase search.

Flag explosion. A codebase with dozens of active flags becomes hard to reason about. Which combinations are valid? Which paths are actually tested? Set an expectation that flags are short-lived, and make stale ones visible — whether through linting, CI checks, or a regular cleanup sprint.

Nested and compound flags. Chaining flags (if flagA && flagB) multiplies the number of states QA needs to validate and makes the logic harder to remove cleanly. If a feature genuinely requires multiple flags in combination, that’s a signal the scope is too large — split the work.

Combinatorial QA complexity. With multiple flags active simultaneously, it’s possible for one flag to inadvertently affect the behavior of another. QA should test flags in isolation first, then in combinations where interaction is likely. Document dependencies explicitly in the PR description.

Accidental release of a stale flag. If a flag removal MR is raised but the guard isn’t fully cleaned — dead code paths left in, old API calls still referenced — the behavior can be unpredictable after release. Code review on flag removal MRs deserves the same attention as the original feature MR.


Some Lessons From Adoption

SSR was the first real challenge. The server can’t see a cookie set client-side after the initial render. The solution was to read the query parameter on the server, setting the cookie in the SSR response, and propagating through Redux — coherent across both SSR and CSR navigation.

CSS changes needed extra thought. Conditional class names work fine, but global style changes require care to ensure flagged styles don’t bleed into the unflagged path.

Platform-wide changes are still handled differently. Migrations touching hundreds of files — test runner upgrades, React version bumps, large-scale refactors — don’t fit inside session flags. A feature branch is still the right tool for platform-level rewrites.

Flag cleanup felt like overhead initially. The follow-up MR seemed cumbersome at first. Once it became clear that the cleanup MR is what generates the release log entry, the team adopted it naturally and consistently.

In summary

Session flags didn’t solve every engineering problem we had. They didn’t eliminate feature branches or replace production feature flags. What they did was remove the biggest bottleneck in our day-to-day delivery process: waiting. Smaller MRs, faster reviews, parallel QA, and continuous integration became the default way of working. For our team, that was enough to cut merge times in half and significantly increase delivery throughput.

The infrastructure cost is low. The cultural shift is harder. But if your team is sitting on 4-day merge times and a QA queue that moves one feature at a time, it’s worth the investment.


Author

  • Technical Architect | Chapter Lead ( Web Platform ) @ OLX India

    Tech enthusiast specializing in building SEO-friendly, scalable, and high-performance e-commerce and CRM applications.

    View all posts