---
title: "Hand-rolled hydration: passing server state to the browser with no framework"
canonical: https://dxdev.com/blog/globalvars-injection-server-state-to-client-no-framework/
datePublished: 2026-02-27
---
I've been running this app for years without React, a JSON API, or a hydration library. It passes server state to the browser by string-concatenating `JSON.stringify()` into an inline `<script>` tag. It worked fine for most of that time. The day it bit me, a client-side filter read a global `sport` variable that always reflected the parent org account, so when an admin drilled into a child event of a different sport, the filter quietly compared against the wrong thing. The fix was one new injected global and one comment.

## The mechanism

The app is a sports SaaS I built on Classic ASP with server-side JScript on IIS. When a page renders, the server builds up a string of JavaScript and dumps it into the page inline. The shape is dead simple:

```javascript
clientJS += 'globalVars.eventsData = ' + JSON.stringify(this.eventsData || []) + ';';
if (this.isSelectEventMode) {
    clientJS += 'globalVars.isSelectEventMode = 1;';
}
```

That string lands in an inline `<script>` block, the browser executes it, and now the client has a pre-populated `globalVars` object full of data the server already knew. That is hydration. There is no framework doing it, no `__INITIAL_STATE__` convention, no serialization layer. It is `+`, `JSON.stringify`, and a semicolon.

The pattern has real virtues. It is dependency-free, and it is auditable in the most literal sense: you can View Source and read the exact JavaScript the server emitted, with the actual values baked in. There is no hydration mismatch warning to chase, because there is no second render to mismatch against. The server says what the client knows, in plain text, once.

It also has a sharp edge I should name before anyone copies the snippet above. `JSON.stringify` produces valid JSON, not something safe to paste into HTML. The moment a serialized string contains `</script>`, the HTML parser closes the block early and the rest of your data becomes page markup. In a sports app the values going through here are team names and usernames, which is to say attacker-controlled text, so this is not hypothetical. The fix is one `replace` at the injection site, escaping the angle bracket so the parser never sees it:

```javascript
function inject(name, value) {
    return name + ' = ' + JSON.stringify(value).replace(/</g, '\\u003c') + ';';
}
```

`<` is still a `<` by the time the JavaScript parser reads the string, so the data is unchanged. The HTML parser just never sees a tag.

The weakness is structural, and it took a feature change to expose it.

## The two ambient globals that were always equal

For most of this app's life, every page read two account objects:

- `currentOrg`, the logged-in account from the URL.
- `orgCtx`, the account whose data you are actually operating on.

Those pointed at the same thing on every request, so the codebase used them interchangeably. Thousands of call sites read one or the other with no thought, because there was no difference to think about.

Then came a new drill-down feature. An org admin could now select a *child* event from a dropdown, scoped by a `?div=<event>` URL param. A server helper mutates the module-level `orgCtx` global mid-request to point at the selected child, while `currentOrg` stays on the parent org. The instant that shipped, `currentOrg.username !== orgCtx.username` became a real possibility, and every line that had treated them as one was now a potential bug.

This is the core tension of hand-rolled hydration. Your injected globals are not one context object. They are several independent variables, and nothing in the pattern tells the client which one is correct for a given job. When there was only ever one true account, that did not matter. The moment there were two, the ambiguity became the entire bug class.

## The bug: a filter reading the wrong global

The cross-account link widget lets you connect your other sites on the platform to a tournament. Part of its job is filtering to same-sport leagues only, so it does not offer to link a soccer site to a baseball tournament. That filter, on the client, compared against a global `sport` var.

Here is the trap. The server set `sport` from `currentOrg`, the org account. Always. So when an org admin drilled into a child event whose sport differed from the parent org, the client filter was still comparing against the parent org's sport. Nothing threw. The filter just produced the wrong set of options, silently, and only in the new drill-into-a-child-event flow that had never existed before. I found it because a user reported that linking sites was offering the wrong leagues, and it took a while to understand why the bug only appeared in a specific sequence of clicks.

You cannot grep your way to confidence on a bug like this. The global `sport` is read in plenty of places that genuinely do want the org's sport. The fix is not to change what `sport` means globally, because that breaks the silent majority of correct callers. The fix is to give the new flow its own, explicitly-named source of truth.

## The fix: one new injected global

In the tournament includes file, inside the script-injection function, the server now injects a second account object carrying the *selected event's* fields:

```javascript
clientJS += 'globalVars.activeCtx = ' + JSON.stringify({
      username: orgCtx.username
    , sport: orgCtx.sport
    , teamname: orgCtx.teamname
  }) + ';';
```

On the client, the filter stopped trusting the ambient `sport` and started preferring the new global, falling back only when it is absent:

```javascript
var activeSport = (globalVars.activeCtx && globalVars.activeCtx.sport)
    ? globalVars.activeCtx.sport
    : sport;
```

That is the whole code change for the sport mismatch. A new bridge variable carrying the event's identity, and a read site that prefers it. `activeCtx` sits alongside `currentOrg` and `orgCtx` on the client now, a third account-shaped thing, each correct for a different purpose.

## The cheapest documentation in the file

Here is the part that matters more than the code. I added a comment in the server source the same day, right where the variable is built:

> Use `globalVars.activeCtx.sport` anywhere the correct sport is needed client-side instead of the global `sport` var which always reflects the logged-in account (currentOrg).

That sentence is the highest-leverage thing changed all day. Think about what the next developer faces without it. They land in a massive client file, they need the current sport, and they see a global called `sport` sitting right there in scope. Of course they reach for it. It is named exactly like what they want. The comment is the only thing standing between them and re-introducing the exact bug that was just fixed, because the trap is that the obvious global is the wrong one.

Hand-rolled hydration has no type system pointing you at the correct context object. It has no `useContext` that throws if you read outside a provider. It has three plausibly-named globals and your own judgment about which one to trust. The comment is what converts "your own judgment" into "the answer is written down right here." It costs one line. The bug it prevents costs a confused user reporting that linking sites offers the wrong leagues, a developer who cannot reproduce it because their test org happens to match the event's sport, and a long hunt to discover the filter trusted the wrong variable.

## Related

- [A fluent DOM builder for legacy ASP: modernize without a rewrite](fluent-dom-builder-modernize-legacy-asp-no-rewrite): the incremental markup-generation companion on the same ASP codebase
- [The Ambient-Global Bug: When "Current Account" Is a Mutable Global Your Whole App Reads](the-ambient-global-bug-mutable-current-account): the deeper pattern of mutable ambient state silently picked up by unrelated callers
- [Server-Side JavaScript on Classic ASP in 2026: Prototype Pages and the DBobj Pattern](server-side-javascript-on-asp-prototype-pages-dbobj): the prototype-based architecture the hydration pattern lives inside
- [A massive JavaScript file and what it tells you about a profitable legacy app](47000-line-js-file-profitable-legacy-app): scale context for why a one-line comment is the highest-leverage change in a large codebase
- [Don't invent a new style, expose the existing one: a product-taste lesson in feature design](expose-the-existing-style-dont-invent-a-parallel-one): adding a new global that mirrors the existing shape rather than inventing a parallel one
