Skip to content

Keeping feed data fresh

An activity’s custom is a snapshot: whatever you post is what every reader sees, forever, until you touch that activity again. That’s fine for a caption. It falls apart the moment something about the post needs to change after it fanned out — “6 spots left” becomes “2 spots left” becomes “full,” and now you’re choosing between stale data and rewriting history.

Two tools solve this, and the choice between them is the whole feature:

Bake it into custom if it is true forever. Store it as an object if it changes.

  • Objects — a {type, id, custom} record (e.g. session:1234) that many activities can point at via a refs array. Update the object once and every timeline holding a ref is fresh on the next read — no re-fan-out, no matter how many activities point at it.
  • PATCH an activity — fixes ONE activity’s own custom. Use it for a typo or a corrected caption, not for anything shared.

Choose by cardinality: patching 500 activities individually is 500 writes. Updating the object they all point at is one.

Create the object and point an activity at it with a ref. Both are server-token only — an object write is a backend operation, not something a viewer does.

import { DropInServer } from '@dropinnodex/server'
const server = new DropInServer({
tenantId: 'acme',
apiKey: process.env.DROPIN_API_KEY!,
apiSecret: process.env.DROPIN_API_SECRET!,
})
// 1. The object — the part that changes.
await server.objects.upsert('session', '1234', { spots_left: 12 })
// 2. The activity — points at it with a ref. `custom` still carries whatever is
// true forever: the caption never changes even though spots_left does.
await server.feed('user', 'alice').addActivity({
verb: 'post',
object: 'session:1234',
custom: { title: 'Thursday 5-a-side' },
refs: ['session:1234'],
})
// 3. Later, spots fill up. One write refreshes every timeline holding the ref —
// zero re-fan-out, regardless of how many activities point at session:1234.
await server.objects.upsert('session', '1234', { spots_left: 2 })

An activity may carry up to 4 refs, each type:id. custom on the object is capped the same way an activity’s is (64 KB, measured on the serialized JSON) — it’s read on every follower’s feed page, so it stays small on purpose.

// Replace `custom` wholesale — creates the object if it doesn't exist yet.
// `custom` is required here: omitting it would wipe the object, so the server
// rejects the call instead of guessing you meant "leave it alone."
await server.objects.upsert('session', '1234', { spots_left: 12, waitlist: false })
// Merge a change into an EXISTING object. 404s if it isn't there — patch never
// creates. `unset` is applied after `set`.
await server.objects.patch('session', '1234', { set: { 'custom.spots_left': 11 } })
await server.objects.patch('session', '1234', { unset: ['custom.waitlist'] })

Updating many objects at once (a nightly sync, a batch price change) is server.batch.objects([...]) — see the migrating existing data guide for the bulk-import shape it shares.

A feed read resolves every ref on the page into an objects sidecar, keyed type:id, in the same three-statement read (spec §6) — no extra round trip per activity.

import { useFeed, resolveRefs } from '@dropinnodex/react'
function Timeline({ uid }: { uid: string }) {
const { activities, objects } = useFeed<{ title: string; spots_left?: number }>('timeline', uid)
return activities.map((activity) => {
// resolveRefs is pure — no fetching. Pick the object you want by `type`,
// never by position (see below). A ref with no stored object (the tenant
// posted before creating one, or it was later removed) is simply skipped,
// never an error, so always fall back to the activity's own custom.
const session = resolveRefs(activity, objects).find((o) => o.type === 'session')
const spotsLeft = session?.custom.spots_left ?? activity.custom.spots_left
return <SessionCard key={activity.id} title={activity.custom.title} spotsLeft={spotsLeft} />
})
}

One layering detail worth knowing before it surprises you: @dropinnodex/client’s feed().get() types objects as optional — the server omits the key entirely when no activity on the page carries a ref. useFeed’s objects is different: it normalizes that to {}, always. Reading straight off the client, check for undefined; reading off useFeed, you never have to.

Removing an object is a hard delete — server.objects.remove('session', '1234'). Activities keep their refs array untouched; the key just stops appearing in the sidecar, so resolveRefs starts skipping it and your fallback to activity.custom takes over automatically.

An object read at 10:00 is a snapshot. By 10:05 the last spot may be gone, and no activity anywhere has been posted. The same is true of an edit: correcting an activity’s caption changes a row that is already on screen.

Neither reaches you through the feed’s change token. That token is advanced by fan-out and by nothing else, so it answers exactly one question — did a new activity arrive — and both an object upsert and an activity edit leave it untouched.

live: true on useFeed covers all three. It head-checks for new activities every 5s, and every 30s revalidates what a head cannot report: edits to the newest page of activities, and the objects every loaded row points at. Everything pauses while the tab is hidden and resumes with an immediate catch-up read.

// Cards re-render when their object moves or their activity is edited.
// No polling code of your own.
const { activities, objects } = useFeed('timeline', uid, { live: true })

The cadences differ deliberately. A head check is one cheap read that usually answers “nothing new”; a revalidation is a real page read plus a batch object read, and edits and object updates happen a few times a day. Tune it with liveRevalidateInterval (milliseconds, default 30000), or pass 0 to switch revalidation off entirely while keeping new-activity detection.

Four things worth knowing:

  • An edit is applied in place. It does not go into the newCount buffer and it never moves a row — a correction should not need a click, and the reader’s scroll position is not yours to shuffle. Only genuinely new activities buffer.
  • Edits are picked up for the newest page only — the revalidation reads page 1, the same page the new-activity check reads. A reader scrolled three pages deep sees edits to the top of the feed, not to the rows they scrolled past; those refresh on the next refresh() or remount. Objects have no such limit: the sweep covers every loaded row, because a batch read of refs is cheap in a way re-reading N pages is not. If the shared, changing part of your card lives in an object rather than in the activity’s own custom, this distinction never reaches your users.
  • A deleted activity is not removed. Page 1 no longer listing a row is indistinguishable from that row having been pushed off page 1 by newer activities, so dropping on absence would delete rows that are merely displaced. Deletions land on the next refresh().
  • Two components on the same feed each read page 1 on their own tick. The object sweep is shared between them; the page read is not, because each hook owns its own activity list and pending buffer.
  • A tab that is left open and never switched away from still updates — the interval runs while visible. What visibility gating buys you is zero requests from backgrounded tabs, which is most of them on mobile.
  • The object sweep reads at most 200 refs per tick — two requests — and refreshes the newest activities first. A feed scrolled far past that keeps its older cards at their last-read values rather than billing you twenty requests every 30 seconds; you get one console warning when it starts happening. liveObjectsMaxRefs moves the ceiling in either direction, at one request per 100 refs.

A timer is the wrong tool for a change the reader just caused. When they book the last spot, they should not wait up to 30 seconds to see it. useFeed returns revalidateObjects() for exactly that — it re-reads the objects behind the activities currently on screen and nothing else:

const { activities, objects, revalidateObjects } = useFeed('timeline', uid)
async function book(sessionId: string) {
await api.bookSpot(sessionId) // your backend, which upserts the object
await revalidateObjects() // the card shows the new count immediately
}

It works with or without live — an app that polls nothing at all can still revalidate after its own writes. Unlike refresh() it touches no pagination, so a reader three pages deep keeps all three and never sees a full-page spinner. It joins the same shared sweep, so it costs one request across every component on that feed, and awaiting it resolves once the rows have actually updated, which makes it safe to drive a pull-to-refresh spinner.

The timer’s cooldown does not apply to it: a hand call is user intent, and answering it with data from a sweep three seconds ago would defeat the point.

Outside React, or for a targeted re-read of refs that are not on screen, use the batch read directly:

// Up to 100 refs in one request, keyed type:id. Refs with no stored object are
// absent from the map — the same contract as the sidecar, never a 404.
const fresh = await client.objects.getMany(['session:1234', 'session:5678'])
const spotsLeft = fresh['session:1234']?.custom.spots_left

Compare each updated_at against the copy you hold and re-render only what moved. getMany is on the server SDK too (server.objects.getMany([...])), for a cache warm or a webhook handler checking what actually changed.

updated_at is safe to compare exactly: every write advances it by at least a millisecond, so two writes to the same object never share a value, even back-to-back inside an import loop.

Note that refresh() is a different tool: it re-reads page 1 and resets the cursor, so a reader who has paged deeper loses those pages. For freshness alone, prefer live, revalidateObjects(), or getMany.

For a typo or a correction that belongs to ONE post — not to anything else that happens to reference the same object — patch the activity directly instead.

// Server token: any activity.
await server.activities.patch(activityId, { set: { 'custom.text': 'Corrected caption' } })
import { DropInClient } from '@dropinnodex/client'
// User token: your OWN activities only.
await client.feed('user', 'alice').updateActivity(activityId, {
set: { 'custom.text': 'Corrected caption' },
})

In React, useFeed exposes the same call, applied optimistically with rollback on failure:

const { updateActivity } = useFeed('user', 'alice')
await updateActivity(activity.id, { set: { 'custom.text': 'Corrected caption' } })

A successful patch returns the activity with edited_at set — null until an activity has been patched for the first time. As with objects, every set/unset path starts with custom.; identity and ordering fields (actor, verb, object, target, time, foreign_id) are never patchable.

Adopting objects on an activity that already exists

Section titled “Adopting objects on an activity that already exists”

refs can only be set at post time — until now. If objects landed after you’d already been posting, every earlier activity had no way to point at one: nothing referenced it, so backfilling the object alone did nothing. refs is patchable too, as a top-level field on the same patch body (not a custom. path, and not merged — it replaces the array wholesale):

await server.activities.patch(activityId, { refs: ['session:1234'] })

refs: [] clears every ref. A body carrying only refs is a valid patch on its own — no set/unset required — and refs can ride alongside them in the same call. It stamps edited_at like any other patch. Unlike custom, refs carries no identity or ordering, so patching it never reorders feeds or touches live cursors — this is the backfill path for every activity you posted before objects existed, without paying for delete-and-repost below.

It’s tempting to just delete the old activity and post a new one with the corrected custom. Don’t — it costs you three things a patch or an object update doesn’t:

  • It burns the foreign_id identity. Deletes are soft; the (foreign_id, time) pair a deleted activity used is gone for good. Re-posting under the same foreign_id returns 409 CONFLICT instead of resurrecting it.
  • It re-fans-out to every follower. A new activity fans out along every follow edge pointing at that feed, all over again — the exact write cost this feature exists to avoid.
  • It jumps the post to the top of every timeline. The new activity gets a new time, so it re-enters at the front of a reverse-chronological feed instead of staying where the original earned its place.

If the data is shared, update the object. If it’s just that one activity, patch it. Delete-and-repost is for when you actually want the post gone and a new one in its place — not for fixing what’s already there.

custom — on activities and on objects — is opaque to dropin. We store it, update it, and return it. We never read it, index it, or interpret it.

That means anything per-viewer is yours to compute, not ours to serve. “3 of your friends joined” isn’t something we can answer — we don’t know who the viewer is relative to anyone else. Put the raw material in the object:

await server.objects.upsert('session', '1234', {
spots_left: 2,
participant_ids: ['alice', 'bob', 'carol'],
})

and derive the per-viewer view client-side, against whatever social graph your app already has:

const mutualCount = session.custom.participant_ids.filter((id) => viewerFriends.has(id)).length

We ship the shared fact (participant_ids); the viewer-relative framing of it is your product, not ours.

  • Activities — posting, foreign_id dedupe, and fan-out.
  • Feeds — where the objects sidecar arrives on a read.
  • Migrating existing databatch.objects for bulk updates.
  • Limits — size and count caps mentioned above.