Skip to content

When to Use Batch vs Streaming for Real-Time AIS Tracking

Use streaming when your pipeline must deliver sub-60-second geofence alerts, live vessel dashboard updates, or collision proximity warnings. Use batch for historical trajectory analytics, regulatory audit reports, and ML training runs over archival NMEA data. In production maritime platforms the two run together: streaming handles live ingestion and alerting, while a nightly batch job reconciles satellite-delayed messages, deduplicates MMSI tracks, and materializes aggregated metrics.


Context: Why the Choice Is Non-Obvious for AIS Data

AIS (Automatic Identification System) is not a clean, uniform data stream. Terrestrial VHF receivers deliver messages with sub-second latency, but satellite uplinks — covering open ocean and polar routes — buffer transmissions for 3 to 15 minutes before downlinking to ground stations. The same MMSI can therefore appear in your broker both as a fresh terrestrial ping and as a stale satellite retransmission from twelve minutes ago, with no reliable ordering guarantee at the global level.

This dual-source problem means naive processing-time windowing produces false geofence triggers and phantom speed violations. The architectural choices you make inside your Batch vs Stream Geospatial Processing design must account for this upstream latency distribution rather than treating AIS as a clean Kafka-style ordered log.

The volume skew compounds the problem. A busy container port such as Rotterdam or Singapore generates thousands of !AIVDM Class A sentences per second within a ~50 km radius, while a vessel crossing the South Atlantic may emit only one satellite uplink per 10 minutes. Any serverless function scaling on message count will auto-scale aggressively near ports and effectively idle at sea, creating cold-start pressure in bursts — the same initialization overhead documented for Python geospatial stacks in Cold Start Mapping for Python GDAL.

The diagram below illustrates where terrestrial and satellite AIS diverge before reaching your processing layer, and why the two paths require different latency budgets.

Terrestrial and satellite AIS arriving on the same streamA sequence showing AIS receivers sending terrestrial sentences to a Kinesis stream in under two seconds and satellite sentences three to fifteen minutes behind the fix, the Lambda consumer reading batches of one hundred records, decoding and indexing each position to an H3 resolution seven cell, dropping anything older than the twenty-minute watermark, and publishing zone-entry alerts to SNS and the archive.One MMSI, two arrival paths, two latency budgetsAIS receiversKinesis streamLambda consumerSNS alert / S3 archiveTerrestrial !AIVDMunder 2 s from the fixSatellite !AIVDM3–15 min behind the fixGetRecords, batch of 100bisect on function errorDecode, H3 r7 cellabout 5 km² per cellDrop if age > 20 minlate fixes go to batchPublish zone entryrestricted or monitoring cell
The same vessel can appear twice in the same batch — once as a fresh terrestrial ping and once as a satellite retransmission of a fix from twelve minutes ago. Nothing in the broker orders them for you.

Prerequisites

Before implementing either path, verify the following:

  • Python 3.11+ with pyais>=2.7, h3>=3.7, boto3>=1.34 (AWS) or google-cloud-pubsub>=2.21 (GCP) or azure-eventhub>=5.11 (Azure).
  • IAM permissions: kinesis:GetRecords, kinesis:GetShardIterator, kinesis:PutRecord (streaming); s3:GetObject, s3:PutObject, glue:GetTable (batch). Scope each to the specific stream ARN or bucket prefix — the principle of least-privilege IAM scoping described in IAM Security Boundaries for Cloud GIS prevents lateral movement if a Lambda execution role is compromised.
  • Environment variables must be set explicitly — do not rely on inherited shell state:
    code
    GDAL_DATA=/opt/share/gdal
    PROJ_LIB=/opt/share/proj
    H3_GEOFENCE_BUCKET=my-geofences
    AIS_STREAM_NAME=ais-ingest-prod
    
  • Platform limits to keep in mind: AWS Lambda 15-minute timeout and 10 GB memory ceiling; GCP Cloud Functions 2nd gen 60-minute timeout and 32 GB memory; Azure Functions consumption plan 10-minute timeout and 1.5 GB memory. Long-running batch reconciliation jobs must run on Fargate, Cloud Run jobs, or Azure Container Apps — not on standard serverless function runtimes that time out.
  • Data format: Raw NMEA 0183 !AIVDM/!AIVDO sentences per ITU-R M.1371-5. Class A messages (types 1, 2, 3) carry position, SOG, and COG; Class B (type 18) carries position only. Your decoder must handle multi-part sentences (the fill_bits field spans two records).

Implementation

The implementation below combines NMEA decoding, H3 spatial indexing, and event-time aware processing into a single Lambda handler. It is designed for an AWS Kinesis trigger but the core decode/geofence logic is broker-agnostic. The one number to settle before writing any of it is the watermark: 20 minutes, chosen because it covers the satellite downlink’s upper bound with enough headroom left for late terrestrial fixes and broker lag.

The twenty-minute event-age budget for AIS streamingA twenty-minute event-age budget split into three parts: fifteen minutes reserved for the satellite downlink buffer, three minutes for late terrestrial arrivals, and two minutes for broker and consumer lag.What the 20-minute watermark is actually paying forSatellite downlink bufferLate arrivalsConsumerlag15 min3 min2 minA terrestrial fix reaches the consumer in under 2 seconds, so the whole budget exists for the satellite path. Anything older is dropped fromthe live path and reclaimed by the nightly batch job.
Set the watermark below 15 minutes and every satellite fix is discarded; set it far above 20 and a twelve-minute-old position can raise a live geofence alert for a vessel that has already left the zone.
python
"""
ais_stream_handler.py — Lambda handler for real-time AIS processing.

Environment variables required:
  GDAL_DATA       = /opt/share/gdal
  PROJ_LIB        = /opt/share/proj
  H3_GEOFENCE_BUCKET = S3 bucket holding geofence_cells.json
  AIS_STREAM_NAME = Kinesis stream name (informational, used in logs)

Trigger: Kinesis Data Stream with batch size 100, bisect-on-error enabled.
"""

import json
import logging
import os
import base64
from datetime import datetime, timezone, timedelta
from typing import Optional

import boto3
import h3
from pyais import decode as ais_decode
from pyais.exceptions import InvalidNMEAMessageException

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# ---------------------------------------------------------------------------
# Geofence index — loaded once per container lifetime (warm-start caching).
# The index is a dict: {"restricted_cells": [...], "monitoring_cells": [...]}
# stored as JSON in S3 and precomputed at H3 resolution 7 (~5 km² per cell).
# ---------------------------------------------------------------------------
_GEOFENCE_INDEX: Optional[dict] = None

def _load_geofence_index() -> dict:
    global _GEOFENCE_INDEX
    if _GEOFENCE_INDEX is not None:
        return _GEOFENCE_INDEX
    s3 = boto3.client("s3")
    bucket = os.environ["H3_GEOFENCE_BUCKET"]
    obj = s3.get_object(Bucket=bucket, Key="geofence_cells.json")
    raw = json.loads(obj["Body"].read())
    # Convert lists to sets for O(1) containment checks.
    _GEOFENCE_INDEX = {
        "restricted_cells": set(raw.get("restricted_cells", [])),
        "monitoring_cells": set(raw.get("monitoring_cells", [])),
    }
    logger.info(
        "Loaded geofence index: %d restricted cells, %d monitoring cells",
        len(_GEOFENCE_INDEX["restricted_cells"]),
        len(_GEOFENCE_INDEX["monitoring_cells"]),
    )
    return _GEOFENCE_INDEX


# ---------------------------------------------------------------------------
# Watermark: reject messages older than MAX_EVENT_AGE_MINUTES to prevent
# stale satellite retransmissions from triggering live geofence alerts.
# 20 minutes absorbs the upper bound of satellite downlink latency (~15 min)
# while still catching most late-arriving terrestrial messages (~2–3 min).
# ---------------------------------------------------------------------------
MAX_EVENT_AGE_MINUTES = 20

# MMSI ranges to drop outright (base stations, aids to navigation, test MMSIs)
_SKIP_MMSI_PREFIXES = (
    "0",    # base stations (00xxxxxxx)
    "99",   # aids to navigation (99xxxxxxx)
    "111",  # SAR aircraft
)


def _decode_record(record: dict) -> Optional[dict]:
    """
    Decode one Kinesis record into a structured AIS position dict.
    Returns None when the record should be silently dropped.
    Raises on hard decode errors so Kinesis bisect-on-error can isolate them.
    """
    # Kinesis wraps payload in base64.
    raw_bytes = base64.b64decode(record["kinesis"]["data"])

    try:
        msg = ais_decode(raw_bytes)
    except InvalidNMEAMessageException as exc:
        # Malformed sentences — log and drop; do not raise (would block the shard).
        logger.warning("Invalid NMEA sentence (dropped): %s — %s", raw_bytes[:80], exc)
        return None

    mmsi = str(getattr(msg, "mmsi", "") or "")
    if any(mmsi.startswith(p) for p in _SKIP_MMSI_PREFIXES):
        return None  # Infrastructure MMSI — not a vessel

    lat = getattr(msg, "lat", None)
    lon = getattr(msg, "lon", None)
    if lat is None or lon is None:
        return None  # Message type does not carry position (e.g., type 5 static data)
    if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
        logger.debug("MMSI %s: out-of-range coordinates (%.4f, %.4f) — dropped", mmsi, lat, lon)
        return None

    # Event time comes from Kinesis approximate arrival, not the NMEA sentence
    # (AIS NMEA carries only a UTC second-of-minute, not a full timestamp).
    event_ts_str = record["kinesis"]["approximateArrivalTimestamp"]
    # approximateArrivalTimestamp is a Unix epoch float from the Kinesis SDK.
    event_ts = datetime.fromtimestamp(float(event_ts_str), tz=timezone.utc)

    age = datetime.now(tz=timezone.utc) - event_ts
    if age > timedelta(minutes=MAX_EVENT_AGE_MINUTES):
        # Late satellite message — route to batch reconciliation, not live alerts.
        logger.info(
            "MMSI %s: message age %s exceeds watermark (%d min) — skipping live path",
            mmsi, age, MAX_EVENT_AGE_MINUTES,
        )
        return None

    sog = getattr(msg, "speed", None)   # Speed over ground in knots
    cog = getattr(msg, "course", None)  # Course over ground in degrees

    # H3 resolution 7: ~5 km² cells, fast enough for port-density volumes.
    h3_cell = h3.latlng_to_cell(lat, lon, 7)

    return {
        "mmsi": mmsi,
        "event_ts": event_ts.isoformat(),
        "lat": lat,
        "lon": lon,
        "sog_knots": sog,
        "cog_deg": cog,
        "h3_r7": h3_cell,
        "msg_type": msg.msg_type,
    }


def _check_geofences(position: dict, index: dict) -> list[str]:
    """Return a list of alert types triggered for this position."""
    alerts = []
    cell = position["h3_r7"]
    if cell in index["restricted_cells"]:
        alerts.append("RESTRICTED_ZONE_ENTRY")
    if cell in index["monitoring_cells"]:
        alerts.append("MONITORING_ZONE_ENTRY")
    # Abnormal speed: SOG > 30 knots is unusual in restricted/coastal zones.
    sog = position.get("sog_knots")
    if sog is not None and sog > 30.0 and cell in index["monitoring_cells"]:
        alerts.append("ABNORMAL_SPEED_IN_ZONE")
    return alerts


def lambda_handler(event: dict, context) -> dict:
    """
    Kinesis trigger entry point.
    Configure the trigger with:
      - BisectBatchOnFunctionError: true   (isolate bad records)
      - StartingPosition: LATEST           (live tracking)
      - BatchSize: 100
      - ParallelizationFactor: 4 per shard (for port-density bursts)
    """
    index = _load_geofence_index()
    records = event.get("Records", [])
    processed = dropped = alerted = 0

    for record in records:
        position = _decode_record(record)
        if position is None:
            dropped += 1
            continue

        alerts = _check_geofences(position, index)
        if alerts:
            # In production: publish to SNS topic or EventBridge for downstream handlers.
            logger.warning(
                "ALERT mmsi=%s ts=%s h3=%s alerts=%s",
                position["mmsi"], position["event_ts"], position["h3_r7"], alerts,
            )
            alerted += 1

        processed += 1

    logger.info(
        "Batch complete: processed=%d dropped=%d alerted=%d total=%d",
        processed, dropped, alerted, len(records),
    )
    return {"processed": processed, "dropped": dropped, "alerted": alerted}

Nightly Batch Reconciliation (PySpark)

The streaming path intentionally drops messages older than 20 minutes. The batch job reclaims them, deduplicates overlapping terrestrial and satellite reports, and writes corrected daily trajectories to partitioned Parquet:

Nightly PySpark reconciliation of the AIS archiveFive ordered steps of the nightly batch job: read the processing day and the prior day's partitions, normalise event time to UTC and filter to the window, deduplicate per MMSI and minute preferring terrestrial over satellite, aggregate the daily trajectory per vessel, and write the day partition to Parquet with overwrite semantics.Nightly reconciliation, five steps1Read day D and day D−1End-of-day fixes downlink after midnight UTC, so the prior day is always in scopespark.read.parquet2Normalise event time to UTCFilter the window on event_ts, never on ingestion timeto_utc_timestamp3Deduplicate per MMSI-minuteWindow on (mmsi, minute); terrestrial beats satellite, then earliest timestamp winsrow_number() == 14Aggregate the daily trajectoryMessage count, mean and max SOG, and the ordered position list per vesselcollect_list5Write the day partitionOverwrite rather than append, so a re-run cannot double-count a vesselpartitionBy(day)
The prior day is always re-read because a fix taken at 23:58 can downlink after midnight UTC. Overwriting the whole day partition is what makes the job safe to re-run after a failed night.
python
"""
ais_batch_reconcile.py — PySpark job for nightly AIS trajectory reconciliation.
Run on EMR Serverless (AWS), Dataproc Serverless (GCP), or Azure Synapse Spark pool.

Environment variables:
  RAW_AIS_PATH    = s3://ais-raw/year={}/month={}/day={}
  ANALYTICS_PATH  = s3://ais-analytics/trajectories/
  PROCESSING_DATE = YYYY-MM-DD (injected by orchestrator)
"""

import os
from datetime import datetime, timedelta
from pyspark.sql import SparkSession, functions as F, Window

spark = SparkSession.builder.appName("ais-reconcile").getOrCreate()

processing_date = os.environ["PROCESSING_DATE"]
dt = datetime.strptime(processing_date, "%Y-%m-%d")
# Include prior day to catch late satellite downlinks (up to 15-min delay
# means end-of-day messages may not appear until early next day UTC).
lookback_start = (dt - timedelta(days=1)).strftime("%Y-%m-%d")

raw_path = os.environ["RAW_AIS_PATH"]
df = spark.read.parquet(
    raw_path.format(dt.year, f"{dt.month:02d}", f"{dt.day:02d}"),
    raw_path.format(
        (dt - timedelta(days=1)).year,
        f"{(dt - timedelta(days=1)).month:02d}",
        f"{(dt - timedelta(days=1)).day:02d}",
    ),
)

# Normalise timestamps and filter to processing window.
df = (
    df.withColumn("event_ts", F.to_utc_timestamp(F.col("event_ts"), "UTC"))
      .filter(F.col("event_ts") >= F.lit(lookback_start))
      .filter(F.col("event_ts") < F.lit((dt + timedelta(days=1)).strftime("%Y-%m-%d")))
)

# Deduplicate: for each (MMSI, minute-bucket), keep the record with the
# highest-quality source (terrestrial preferred over satellite) then earliest ts.
dedup_window = Window.partitionBy(
    "mmsi", F.date_trunc("minute", "event_ts")
).orderBy(
    F.col("source_type").asc(),  # "terrestrial" < "satellite" alphabetically
    F.col("event_ts").asc(),
)
df_deduped = (
    df.withColumn("row_num", F.row_number().over(dedup_window))
      .filter(F.col("row_num") == 1)
      .drop("row_num")
)

# Aggregate daily trajectory per vessel.
trajectory_df = (
    df_deduped
    .groupBy("mmsi", F.date_trunc("day", "event_ts").alias("day"))
    .agg(
        F.count("*").alias("message_count"),
        F.avg("sog_knots").alias("avg_sog_knots"),
        F.max("sog_knots").alias("max_sog_knots"),
        F.collect_list(
            F.struct("event_ts", "lat", "lon", "sog_knots", "cog_deg", "h3_r7")
        ).alias("trajectory"),
    )
)

# Write with Z-ordering on MMSI for efficient per-vessel queries.
(
    trajectory_df.write
    .mode("overwrite")
    .partitionBy("day")
    .parquet(os.environ["ANALYTICS_PATH"])
)

print(f"Reconciliation complete for {processing_date}: {df_deduped.count():,} deduped records")

Verification

Run this against a single Kinesis shard to confirm the streaming handler is decoding and watermarking correctly before full deployment:

python
"""
verify_ais_handler.py — Smoke test for the streaming Lambda handler.
Run locally with AWS credentials configured.
"""

import base64
import json
from datetime import datetime, timezone
from unittest.mock import patch, MagicMock

# Minimal valid AIS Class A position report (MMSI 123456789, 51.5°N 0.1°W)
SAMPLE_NMEA = b"!AIVDM,1,1,,A,13HOI:0P0000W9N0000000000000,0*1B"

def make_kinesis_record(nmea_bytes: bytes, age_seconds: float = 2.0) -> dict:
    ts = (datetime.now(tz=timezone.utc).timestamp() - age_seconds)
    return {
        "kinesis": {
            "data": base64.b64encode(nmea_bytes).decode(),
            "approximateArrivalTimestamp": str(ts),
        }
    }

# Patch S3 geofence load so the test runs without live AWS access.
mock_s3 = MagicMock()
mock_s3.get_object.return_value = {
    "Body": MagicMock(read=lambda: json.dumps({
        "restricted_cells": [],
        "monitoring_cells": [],
    }).encode())
}

with patch("boto3.client", return_value=mock_s3):
    import ais_stream_handler as handler
    handler._GEOFENCE_INDEX = None  # reset warm-state cache

    event = {"Records": [make_kinesis_record(SAMPLE_NMEA, age_seconds=2.0)]}
    result = handler.lambda_handler(event, None)

assert result["processed"] == 1, f"Expected 1 processed, got {result}"
assert result["dropped"] == 0, f"Expected 0 dropped, got {result}"
print("PASS:", result)
# Expected output: PASS: {'processed': 1, 'dropped': 0, 'alerted': 0}

# Verify watermark rejects old satellite messages (>20 min).
event_stale = {"Records": [make_kinesis_record(SAMPLE_NMEA, age_seconds=1300.0)]}
with patch("boto3.client", return_value=mock_s3):
    handler._GEOFENCE_INDEX = None
    result_stale = handler.lambda_handler(event_stale, None)

assert result_stale["processed"] == 0, "Stale satellite message should be dropped"
print("PASS watermark:", result_stale)
# Expected output: PASS watermark: {'processed': 0, 'dropped': 1, 'alerted': 0}

The batch reconciliation job can be verified by checking that the output Parquet partition count matches raw input record count minus expected duplicates:

bash
# On EMR or local PySpark shell:
python -c "
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
df = spark.read.parquet('s3://ais-analytics/trajectories/day=2024-10-01/')
print('Vessel trajectories:', df.count())
df.select('mmsi','message_count','avg_sog_knots').orderBy('message_count', ascending=False).show(10)
"

Expected: message_count values in the thousands for busy MMSI ranges near ports, single digits for open-ocean vessels.


Gotchas and Edge Cases

  • Multi-part NMEA sentences must be buffered before decoding. Class B !AIVDM type-24 static reports split across two sentences with the same sequential message ID. If your Kinesis shard delivers part 1 and part 2 in separate batches (possible with record-level partitioning by MMSI), pyais.decode() will raise MissingMultipartMessageException. Buffer multi-part fragments in ElastiCache or a DynamoDB TTL table keyed on (mmsi, sequence_id) and only call decode once both parts arrive.

  • H3 cell boundaries create split geofence detections at resolution 7. A vessel crossing a port breakwater may straddle two H3 cells, triggering entry and exit alerts in the same second. Expand your restricted-cell sets using h3.grid_disk(cell, 1) (the immediate ring of 6 neighbours) to create a 1-cell buffer zone, then only alert when the vessel has been inside the buffer-excluded core for two consecutive messages.

  • Azure Event Hubs partitions by partition key, not MMSI. If you hash on MMSI % partition_count, popular MMSI ranges (Class A vessels in major shipping lanes) will create hot partitions. Use a two-level hash: hash(mmsi + hour_bucket) % partition_count to spread temporal bursts across partitions while keeping per-vessel ordering within a processing window. SQS and Pub/Sub Queue Routing Strategies covers hot-partition mitigation in detail.

  • The !AIVDO sentence type (own-vessel) must be excluded from aggregation. !AIVDO originates from your own receiving hardware, not a remote vessel. Including it in MMSI aggregates skews trajectory counts and can produce circular self-geofencing alerts. Filter on sentence type before any spatial processing: if raw_bytes.startswith(b"!AIVDO"): return None.


Frequently Asked Questions

What latency is achievable with streaming AIS on AWS Kinesis?

End-to-end latency from terrestrial AIS station to geofence alert is typically 2–8 seconds using Kinesis Data Streams with Enhanced Fan-Out and a Lambda consumer. Satellite AIS adds an upstream delay of 3–15 minutes before the message even reaches your broker — that delay is outside your control and is why the watermark approach is necessary.

Can batch processing handle AIS collision avoidance alerts?

No. Collision avoidance requires sub-30-second response windows. Batch pipelines with 15-minute minimum latency are unsuitable; only streaming with in-memory state (Redis sorted sets for MMSI proximity) meets the operational requirement.

How do you handle duplicate MMSI transmissions in a streaming pipeline?

Deduplicate at the consumer using a sliding-window bloom filter keyed on (MMSI, timestamp_minute). For persistent sinks, use conditional upserts — DynamoDB condition expressions or BigTable row-level versioning — so replayed messages do not overwrite newer positions.


Back to Batch vs Stream Geospatial Processing